@stonyx/orm 0.3.2-alpha.7 → 0.3.2-alpha.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +870 -11
  2. package/config/environment.js +8 -0
  3. package/dist/commands.js +34 -0
  4. package/dist/dynamodb/connection.d.ts +31 -0
  5. package/dist/dynamodb/connection.js +28 -0
  6. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  7. package/dist/dynamodb/dynamodb-db.js +596 -0
  8. package/dist/dynamodb/operation-builder.d.ts +76 -0
  9. package/dist/dynamodb/operation-builder.js +116 -0
  10. package/dist/dynamodb/type-map.d.ts +31 -0
  11. package/dist/dynamodb/type-map.js +48 -0
  12. package/dist/hooks.d.ts +15 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/main.d.ts +116 -0
  15. package/dist/main.js +129 -0
  16. package/dist/manage-record.js +268 -12
  17. package/dist/mysql/connection.d.ts +1 -0
  18. package/dist/mysql/mysql-db.d.ts +8 -0
  19. package/dist/mysql/mysql-db.js +44 -10
  20. package/dist/orm-request.d.ts +264 -3
  21. package/dist/orm-request.js +975 -49
  22. package/dist/postgres/connection.d.ts +1 -0
  23. package/dist/postgres/connection.js +8 -6
  24. package/dist/postgres/postgres-db.d.ts +8 -0
  25. package/dist/postgres/postgres-db.js +44 -10
  26. package/dist/record.js +7 -5
  27. package/dist/relationships.js +1 -1
  28. package/dist/serializer.js +38 -2
  29. package/dist/setup-rest-server.js +51 -5
  30. package/dist/standalone-db.js +17 -5
  31. package/dist/store.d.ts +13 -1
  32. package/dist/store.js +65 -6
  33. package/dist/types/orm-types.d.ts +207 -0
  34. package/dist/utils.d.ts +44 -0
  35. package/dist/utils.js +47 -0
  36. package/package.json +16 -7
  37. package/src/commands.ts +43 -0
  38. package/src/dynamodb/connection.ts +50 -0
  39. package/src/dynamodb/dynamodb-db.ts +811 -0
  40. package/src/dynamodb/operation-builder.ts +202 -0
  41. package/src/dynamodb/type-map.ts +54 -0
  42. package/src/hooks.ts +15 -1
  43. package/src/index.ts +1 -0
  44. package/src/main.ts +133 -0
  45. package/src/manage-record.ts +294 -18
  46. package/src/mysql/connection.ts +1 -0
  47. package/src/mysql/mysql-db.ts +44 -12
  48. package/src/orm-request.ts +992 -52
  49. package/src/postgres/connection.ts +10 -6
  50. package/src/postgres/postgres-db.ts +44 -12
  51. package/src/record.ts +8 -5
  52. package/src/relationships.ts +1 -1
  53. package/src/serializer.ts +39 -2
  54. package/src/setup-rest-server.ts +59 -6
  55. package/src/standalone-db.ts +17 -6
  56. package/src/store.ts +68 -6
  57. package/src/types/orm-types.ts +214 -0
  58. package/src/types/stonyx-rest-server.d.ts +14 -1
  59. package/src/types/stonyx.d.ts +7 -1
  60. package/src/utils.ts +50 -0
@@ -8,6 +8,7 @@ interface PgConfig {
8
8
  password: string;
9
9
  database: string;
10
10
  connectionLimit: number;
11
+ [key: string]: unknown;
11
12
  }
12
13
 
13
14
  let pool: PgPool | null = null;
@@ -20,15 +21,18 @@ export async function getPool(pgConfig: PgConfig, extensions: string[] = ['vecto
20
21
 
21
22
  const { default: pg } = await import('pg');
22
23
 
24
+ const { host, port, user, password, database, connectionLimit, migrationsDir, migrationsTable, autoMigrate, ...poolOpts } = pgConfig;
25
+
23
26
  pool = new pg.Pool({
24
- host: pgConfig.host,
25
- port: pgConfig.port,
26
- user: pgConfig.user,
27
- password: pgConfig.password,
28
- database: pgConfig.database,
29
- max: pgConfig.connectionLimit,
27
+ host,
28
+ port,
29
+ user,
30
+ password,
31
+ database,
32
+ max: connectionLimit,
30
33
  idleTimeoutMillis: 30000,
31
34
  connectionTimeoutMillis: 10000,
35
+ ...poolOpts,
32
36
  });
33
37
 
34
38
  // Enable requested PostgreSQL extensions
@@ -90,6 +90,15 @@ export default class PostgresDB {
90
90
  pool!: Pool | null;
91
91
  pgConfig!: Record<string, unknown>;
92
92
 
93
+ /**
94
+ * Promise-chain mutex for write serialization (#156).
95
+ * All persist() calls chain through this single queue so concurrent
96
+ * fire-and-forget writes never produce parallel transactions
97
+ * on FK-linked rows (which cause deadlocks).
98
+ * Reads are NOT affected — only persist() serializes.
99
+ */
100
+ private _writeQueue: Promise<void> = Promise.resolve();
101
+
93
102
  constructor(deps: Partial<PostgresDeps> = {}) {
94
103
  const Ctor = this.constructor as typeof PostgresDB;
95
104
  if (Ctor.instance) return Ctor.instance;
@@ -125,7 +134,15 @@ export default class PostgresDB {
125
134
  if (pending.length > 0) {
126
135
  this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
127
136
 
128
- const shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
137
+ let shouldApply: boolean;
138
+ if (this.pgConfig.autoMigrate === true) {
139
+ shouldApply = true;
140
+ } else if (this.pgConfig.autoMigrate === false) {
141
+ shouldApply = false;
142
+ this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
143
+ } else {
144
+ shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
145
+ }
129
146
 
130
147
  if (shouldApply) {
131
148
  for (const filename of pending) {
@@ -146,9 +163,17 @@ export default class PostgresDB {
146
163
  const modelCount = Object.keys(schemas).length;
147
164
 
148
165
  if (modelCount > 0) {
149
- const shouldGenerate = await this.deps.confirm(
150
- `No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
151
- );
166
+ let shouldGenerate: boolean;
167
+ if (this.pgConfig.autoMigrate === true) {
168
+ shouldGenerate = true;
169
+ } else if (this.pgConfig.autoMigrate === false) {
170
+ shouldGenerate = false;
171
+ this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
172
+ } else {
173
+ shouldGenerate = await this.deps.confirm(
174
+ `No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
175
+ );
176
+ }
152
177
 
153
178
  if (shouldGenerate) {
154
179
  const { generateMigration } = await import('./migration-generator.js');
@@ -468,14 +493,21 @@ export default class PostgresDB {
468
493
  const Orm = (await import('@stonyx/orm')).default;
469
494
  if ((Orm.instance as { isView?: (name: string) => boolean })?.isView?.(modelName)) return;
470
495
 
471
- switch (operation) {
472
- case 'create':
473
- return this._persistCreate(modelName, context, response);
474
- case 'update':
475
- return this._persistUpdate(modelName, context, response);
476
- case 'delete':
477
- return this._persistDelete(modelName, context);
478
- }
496
+ const work = async () => {
497
+ switch (operation) {
498
+ case 'create':
499
+ return this._persistCreate(modelName, context, response);
500
+ case 'update':
501
+ return this._persistUpdate(modelName, context, response);
502
+ case 'delete':
503
+ return this._persistDelete(modelName, context);
504
+ }
505
+ };
506
+
507
+ // Chain through the write queue — .then(work, work) ensures the queue
508
+ // advances even when a previous persist rejects (#156).
509
+ this._writeQueue = this._writeQueue.then(work, work);
510
+ return this._writeQueue;
479
511
  }
480
512
 
481
513
  private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
package/src/record.ts CHANGED
@@ -87,12 +87,15 @@ export default class Record {
87
87
 
88
88
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
89
89
  if (Array.isArray(childRecord)) {
90
+ // Filter out cleaned records (those with no __model)
91
+ const live = childRecord.filter((r: Record) => r?.__model);
92
+
90
93
  // Deduplicate by record ID — keep last occurrence (latest data wins)
91
94
  const seen = new Set<unknown>();
92
95
  const unique: Record[] = [];
93
96
 
94
- for (let i = childRecord.length - 1; i >= 0; i--) {
95
- const r = childRecord[i] as Record;
97
+ for (let i = live.length - 1; i >= 0; i--) {
98
+ const r = live[i] as Record;
96
99
  if (!seen.has(r.id)) {
97
100
  seen.add(r.id);
98
101
  unique.push(r);
@@ -102,7 +105,7 @@ export default class Record {
102
105
  unique.reverse();
103
106
  records[key] = unique.map((r: Record) => r.serialize());
104
107
  } else {
105
- records[key] = (childRecord as Record)?.serialize() ?? null;
108
+ records[key] = (childRecord as Record)?.__model ? (childRecord as Record).serialize() : null;
106
109
  }
107
110
  }
108
111
 
@@ -136,8 +139,8 @@ export default class Record {
136
139
  if (fields && !fields.has(key)) continue;
137
140
 
138
141
  const relationshipData = Array.isArray(childRecord)
139
- ? childRecord.map((r: Record) => ({ type: r.__model.__name, id: r.id }))
140
- : childRecord ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
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;
141
144
 
142
145
  // Dasherize the key for URL paths (e.g., accessLinks -> access-links)
143
146
  const dasherizedKey = camelCaseToKebabCase(key);
@@ -51,4 +51,4 @@ export function getPendingBelongsToRegistry(): PendingBelongsToMap {
51
51
  return relationships.get('pendingBelongsTo') as PendingBelongsToMap;
52
52
  }
53
53
 
54
- export const TYPES: string[] = ['global', 'hasMany', 'belongsTo', 'pending'];
54
+ export const TYPES: string[] = ['global', 'hasMany', 'belongsTo', 'pending', 'pendingBelongsTo'];
package/src/serializer.ts CHANGED
@@ -94,8 +94,45 @@ export default class Serializer {
94
94
  const handlerOptions = { ...options, _relationshipKey: key };
95
95
  const childRecord = handler(record, data, handlerOptions);
96
96
 
97
- rec[key] = childRecord;
98
- relatedRecords[key] = childRecord;
97
+ // hasMany relationships use a getter so format()/toJSON() always read
98
+ // the live registry array instead of a stale snapshot captured at
99
+ // serialization time. This is critical when child records are created
100
+ // in a later async frame — the belongsTo inverse wiring pushes into
101
+ // the shared registry array, and the getter ensures the parent sees it.
102
+ const isHasMany = (handler as { __relationshipType?: string }).__relationshipType === 'hasMany';
103
+
104
+ if (isHasMany) {
105
+ // `childRecord` IS the shared registry array — define a getter that
106
+ // always dereferences through the same array reference.
107
+ const registryArray = childRecord as unknown[];
108
+ Object.defineProperty(rec, key, {
109
+ enumerable: true,
110
+ configurable: true,
111
+ get: () => registryArray,
112
+ set(v: unknown) { relatedRecords[key] = v; }
113
+ });
114
+ Object.defineProperty(relatedRecords, key, {
115
+ enumerable: true,
116
+ configurable: true,
117
+ get: () => registryArray,
118
+ set(v: unknown) { Object.defineProperty(relatedRecords, key, { value: v, writable: true, enumerable: true, configurable: true }); }
119
+ });
120
+ } else {
121
+ rec[key] = childRecord;
122
+ relatedRecords[key] = childRecord;
123
+
124
+ // Preserve the raw FK value in __data when the belongsTo handler
125
+ // couldn't resolve the target (e.g., memory:false model not loaded).
126
+ // This allows adapters to read the FK from __data as a fallback
127
+ // when __relationships[key] is null. Only store when `data` is a
128
+ // truthy non-object — i.e., a raw FK string/number that the handler
129
+ // attempted but failed to resolve. When `data` is null/undefined
130
+ // (optional empty relationship) we intentionally skip to preserve
131
+ // the existing behavior of not populating __data for empty FKs.
132
+ if (childRecord === null && data && typeof data !== 'object') {
133
+ parsedData[key] = data;
134
+ }
135
+ }
99
136
 
100
137
  continue;
101
138
  }
@@ -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';
@@ -7,14 +7,20 @@ import { forEachFileImport } from '@stonyx/utils/file';
7
7
  import { dbKey } from './db.js';
8
8
  import { getPluralName } from './plural-registry.js';
9
9
  import log from 'stonyx/log';
10
+ import type { AccessFunction } from './types/orm-types.js';
10
11
 
11
12
  interface AccessInstance {
12
13
  models: string[] | '*';
13
- access: (request: unknown) => unknown;
14
+ /**
15
+ * The consumer predicate. Called as `access(request, { model, operation })`
16
+ * -- the second argument is additive (abofs/stonyx-orm#202), so a predicate
17
+ * declared with a single parameter is still valid and still works.
18
+ */
19
+ access: AccessFunction;
14
20
  }
15
21
 
16
22
  export default async function(route: string, accessPath: string, metaRoute: boolean): Promise<void> {
17
- const accessFiles: Record<string, (request: unknown) => unknown> = {};
23
+ const accessFunctions: Record<string, AccessFunction> = {};
18
24
 
19
25
  try {
20
26
  await forEachFileImport(accessPath, (accessClass: unknown) => {
@@ -31,9 +37,9 @@ export default async function(route: string, accessPath: string, metaRoute: bool
31
37
  for (const model of models === '*' ? availableModels : models) {
32
38
  if (model === dbKey) continue;
33
39
  if (!store.data.has(model)) throw new Error(`Unable to define access for Invalid Model "${model}". Model does not exist`);
34
- if (accessFiles![model]) throw new Error(`Access for model "${model}" has already been defined by another access class.`);
40
+ if (accessFunctions![model]) throw new Error(`Access for model "${model}" has already been defined by another access class.`);
35
41
 
36
- accessFiles![model] = accessInstance.access;
42
+ accessFunctions![model] = accessInstance.access;
37
43
  }
38
44
  });
39
45
  } catch (error) {
@@ -41,13 +47,60 @@ export default async function(route: string, accessPath: string, metaRoute: bool
41
47
  log.warn?.('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
42
48
  }
43
49
 
50
+ // -------------------------------------------------------------------------
51
+ // #202 -- the registry has to survive this function.
52
+ //
53
+ // `accessFunctions` used to be a function-local that was discarded at the return
54
+ // below, so the only thing that ever saw it was the mount loop. Each mounted
55
+ // OrmRequest then held its OWN model's predicate and nothing held the map, so
56
+ // at request time there was no route from a model NAME to that model's
57
+ // predicate -- which is what abofs/stonyx-orm#196 and #207 need in order to
58
+ // ask model X's predicate about a request routed to model Y.
59
+ //
60
+ // Published BEFORE `await waitForModule('rest-server')`, deliberately: that
61
+ // await is the ONLY yield point in this function, and the rest-server module
62
+ // may already be listening by the time it reports ready, so an assignment
63
+ // after it would leave a window in which a route is live and the registry is
64
+ // not.
65
+ //
66
+ // It is NOT before the mount loop for that reason, and the comment here used
67
+ // to say it was. `RestServer.mountRoute` is fully synchronous -- construct,
68
+ // registerCalls(), api.use() -- and nothing between the loop and this
69
+ // function's closing brace yields, so the event loop cannot deliver a request
70
+ // in there and the window that clause described cannot open. Measured:
71
+ // moving this assignment to the last statement of the function leaves the
72
+ // suite at 951 pass / 0 fail. Being ahead of the mount loop is free and
73
+ // harmless; it is not what makes the ordering correct.
74
+ //
75
+ // Assigned unconditionally, including when the try above failed and the map
76
+ // is empty or partial: the mount loop below is driven by this exact object,
77
+ // so at the moment of assignment whatever is reachable through
78
+ // `Orm.instance` is the same set of predicates that is about to enforce.
79
+ // A guard such as `if (Object.keys(accessFunctions).length)` would let the
80
+ // registry go silently missing on a total load failure, and a later consumer
81
+ // would read `undefined` from `getAccess` and have to distinguish "no access
82
+ // class" from "the registry was never published" -- which it cannot. That is
83
+ // the reasoning, and it is REASONING, not something this suite tests: the
84
+ // guarded variant is also 951 pass / 0 fail, AC8 included, because every boot
85
+ // in this suite loads a non-empty access map so the guard never fires. AC8
86
+ // demonstrably cannot catch it. Catching it needs a boot with
87
+ // `orm.paths.access` pointed at an empty directory, which this suite has no
88
+ // harness for.
89
+ //
90
+ // One further limit on "by construction": the mount loop passes `access` BY
91
+ // VALUE into each OrmRequest, so the enforcing set is a snapshot taken here,
92
+ // while `getAccess` reads the map live. The two are the same set at boot and
93
+ // stay the same set only for as long as nobody writes to the public field.
94
+ // The equality is a boot-time fact, not an invariant.
95
+ Orm.instance.accessFunctions = accessFunctions;
96
+
44
97
  await waitForModule('rest-server');
45
98
 
46
99
  // Remove "/" prefix and name mount point accordingly
47
100
  const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
48
101
 
49
102
  // Configure endpoints for models and views with access configuration
50
- for (const [model, access] of Object.entries(accessFiles!)) {
103
+ for (const [model, access] of Object.entries(accessFunctions!)) {
51
104
  const pluralizedModel = getPluralName(model);
52
105
  const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
53
106
  RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
@@ -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
- const maxId = records.reduce((max, r) => {
135
- const rid = typeof r.id === 'number' ? r.id : 0;
136
- return rid > max ? rid : max;
137
- }, 0);
138
-
139
- data.id = maxId + 1;
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/store.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import Orm, { relationships } from '@stonyx/orm';
2
- import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry } from './relationships.js';
2
+ import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry, getPendingBelongsToRegistry } from './relationships.js';
3
3
  import ViewResolver from './view-resolver.js';
4
4
 
5
5
  interface UnloadOptions {
@@ -64,7 +64,7 @@ export default class Store {
64
64
  get(key: string): Map<number | string, unknown> | undefined;
65
65
  get(key: string, id: number | string): unknown;
66
66
  get(key: string, id?: number | string): Map<number | string, unknown> | unknown | undefined {
67
- if (!id) return this.data.get(key);
67
+ if (id === undefined) return this.data.get(key);
68
68
 
69
69
  return this.data.get(key)?.get(id);
70
70
  }
@@ -170,14 +170,15 @@ export default class Store {
170
170
  this.data.set(key, value);
171
171
  }
172
172
 
173
- remove(key: string, id?: number | string): void {
173
+ remove(key: string, id?: number | string, options?: { _skipAutoPersist?: boolean }): void {
174
174
  // Guard: read-only views cannot have records removed
175
175
  if (Orm.instance?.isView?.(key)) {
176
176
  throw new Error(`Cannot remove records from read-only view '${key}'`);
177
177
  }
178
178
 
179
- // Auto-persist delete to SQL
180
- if (id && Orm.instance?.sqlDb) {
179
+ // Auto-persist delete to SQL (fire-and-forget) — skipped when the
180
+ // request path handles persist itself to avoid double-delete.
181
+ if (id && Orm.instance?.sqlDb && !options?._skipAutoPersist) {
181
182
  Orm.instance.sqlDb.persist('delete', key, { recordId: id }, {}).catch((err: unknown) => {
182
183
  Orm.instance.emitPersistError({
183
184
  operation: 'delete',
@@ -193,6 +194,44 @@ export default class Store {
193
194
  this.unloadAllRecords(key);
194
195
  }
195
196
 
197
+ /**
198
+ * Evict a record from the store with full relationship registry cleanup.
199
+ * The caller retains its reference to the returned record, which is the
200
+ * contract memory:false post-persist eviction relies on.
201
+ *
202
+ * @param registryId - The ID used when the record's relationships were
203
+ * registered. For SQL models with pending IDs, this is the original
204
+ * negative pending ID (before the adapter re-keyed to the real DB ID).
205
+ */
206
+ evictRecord(modelName: string, id: unknown, registryId?: unknown): void {
207
+ const modelStore = this.data.get(modelName);
208
+ if (!modelStore) return;
209
+
210
+ if (typeof id !== 'string' && typeof id !== 'number') return;
211
+ const raw = modelStore.get(id);
212
+ if (!raw || !isStoreRecord(raw)) return;
213
+
214
+ const visited = new Set([`${modelName}:${id}`]);
215
+
216
+ // Remove from hasMany arrays and nullify belongsTo references using current ID
217
+ // (the adapter updates record.id, so value-based matches need the current ID)
218
+ this._removeFromHasManyArrays(modelName, id, visited);
219
+ this._nullifyBelongsToReferences(modelName, id, visited);
220
+
221
+ // Clean up relationship registry entries using the registry key
222
+ // (belongsTo/hasMany registries were keyed by the ID at registration time,
223
+ // which may differ from the current ID if SQL persist re-keyed the record)
224
+ const cleanupId = registryId ?? id;
225
+ this._cleanupRelationshipRegistries(modelName, cleanupId);
226
+
227
+ // If registryId differs from id, also clean with current id as safety net
228
+ if (registryId !== undefined && registryId !== id) {
229
+ this._cleanupRelationshipRegistries(modelName, id);
230
+ }
231
+
232
+ modelStore.delete(id);
233
+ }
234
+
196
235
  unloadRecord(model: string, id: unknown, options: UnloadOptions = {}): void {
197
236
  const modelStore = this.data.get(model);
198
237
 
@@ -219,7 +258,6 @@ export default class Store {
219
258
  this._removeFromHasManyArrays(modelName, recordId, visited);
220
259
  this._nullifyBelongsToReferences(modelName, recordId, visited);
221
260
  this._cleanupRelationshipRegistries(modelName, recordId);
222
- recordToUnload.clean();
223
261
 
224
262
  this.data.get(modelName)?.delete(recordId as string | number);
225
263
  }
@@ -308,6 +346,30 @@ export default class Store {
308
346
 
309
347
  const pendingMap = getPendingRegistry().get(modelName);
310
348
  if (pendingMap) pendingMap.delete(recordId);
349
+
350
+ // Clean pendingBelongsTo entries in both directions
351
+ const pendingBelongsToMap = getPendingBelongsToRegistry();
352
+ if (pendingBelongsToMap) {
353
+ // Direction 1: evicted record was the TARGET others were waiting for
354
+ const targetEntries = pendingBelongsToMap.get(modelName);
355
+ if (targetEntries) targetEntries.delete(recordId);
356
+
357
+ // Direction 2: evicted record was the SOURCE with unresolved forward-references
358
+ for (const [, targetIdMap] of pendingBelongsToMap) {
359
+ for (const [targetId, entries] of targetIdMap) {
360
+ if (!Array.isArray(entries)) continue;
361
+ const filtered = entries.filter((e: unknown) => {
362
+ const entry = e as { sourceModelName?: string; relationshipId?: unknown };
363
+ return !(entry.sourceModelName === modelName && entry.relationshipId === recordId);
364
+ });
365
+ if (filtered.length === 0) {
366
+ targetIdMap.delete(targetId);
367
+ } else if (filtered.length < entries.length) {
368
+ targetIdMap.set(targetId, filtered);
369
+ }
370
+ }
371
+ }
372
+ }
311
373
  }
312
374
 
313
375
  /**