@stonyx/orm 0.2.5-alpha.0 → 0.3.1

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 (166) hide show
  1. package/README.md +482 -15
  2. package/config/environment.js +63 -6
  3. package/dist/aggregates.d.ts +21 -0
  4. package/dist/aggregates.js +93 -0
  5. package/dist/attr.d.ts +2 -0
  6. package/dist/attr.js +22 -0
  7. package/dist/belongs-to.d.ts +11 -0
  8. package/dist/belongs-to.js +59 -0
  9. package/dist/cli.d.ts +22 -0
  10. package/dist/cli.js +148 -0
  11. package/dist/commands.d.ts +7 -0
  12. package/dist/commands.js +146 -0
  13. package/dist/db.d.ts +21 -0
  14. package/dist/db.js +180 -0
  15. package/dist/exports/db.d.ts +7 -0
  16. package/{src → dist}/exports/db.js +2 -4
  17. package/dist/has-many.d.ts +11 -0
  18. package/dist/has-many.js +58 -0
  19. package/dist/hooks.d.ts +75 -0
  20. package/dist/hooks.js +110 -0
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.js +34 -0
  23. package/dist/main.d.ts +46 -0
  24. package/dist/main.js +181 -0
  25. package/dist/manage-record.d.ts +13 -0
  26. package/dist/manage-record.js +123 -0
  27. package/dist/meta-request.d.ts +6 -0
  28. package/dist/meta-request.js +52 -0
  29. package/dist/migrate.d.ts +2 -0
  30. package/dist/migrate.js +57 -0
  31. package/dist/model-property.d.ts +9 -0
  32. package/dist/model-property.js +29 -0
  33. package/dist/model.d.ts +15 -0
  34. package/dist/model.js +18 -0
  35. package/dist/mysql/connection.d.ts +14 -0
  36. package/dist/mysql/connection.js +24 -0
  37. package/dist/mysql/migration-generator.d.ts +45 -0
  38. package/dist/mysql/migration-generator.js +254 -0
  39. package/dist/mysql/migration-runner.d.ts +12 -0
  40. package/dist/mysql/migration-runner.js +88 -0
  41. package/dist/mysql/mysql-db.d.ts +100 -0
  42. package/dist/mysql/mysql-db.js +425 -0
  43. package/dist/mysql/query-builder.d.ts +10 -0
  44. package/dist/mysql/query-builder.js +44 -0
  45. package/dist/mysql/schema-introspector.d.ts +19 -0
  46. package/dist/mysql/schema-introspector.js +257 -0
  47. package/dist/mysql/type-map.d.ts +21 -0
  48. package/dist/mysql/type-map.js +36 -0
  49. package/dist/orm-request.d.ts +38 -0
  50. package/dist/orm-request.js +475 -0
  51. package/dist/plural-registry.d.ts +4 -0
  52. package/dist/plural-registry.js +9 -0
  53. package/dist/postgres/connection.d.ts +15 -0
  54. package/dist/postgres/connection.js +32 -0
  55. package/dist/postgres/migration-generator.d.ts +45 -0
  56. package/dist/postgres/migration-generator.js +280 -0
  57. package/dist/postgres/migration-runner.d.ts +10 -0
  58. package/dist/postgres/migration-runner.js +87 -0
  59. package/dist/postgres/postgres-db.d.ts +119 -0
  60. package/dist/postgres/postgres-db.js +477 -0
  61. package/dist/postgres/query-builder.d.ts +27 -0
  62. package/dist/postgres/query-builder.js +98 -0
  63. package/dist/postgres/schema-introspector.d.ts +29 -0
  64. package/dist/postgres/schema-introspector.js +296 -0
  65. package/dist/postgres/type-map.d.ts +23 -0
  66. package/dist/postgres/type-map.js +56 -0
  67. package/dist/record.d.ts +75 -0
  68. package/dist/record.js +129 -0
  69. package/dist/relationships.d.ts +10 -0
  70. package/dist/relationships.js +41 -0
  71. package/dist/schema-helpers.d.ts +20 -0
  72. package/dist/schema-helpers.js +48 -0
  73. package/dist/serializer.d.ts +17 -0
  74. package/dist/serializer.js +136 -0
  75. package/dist/setup-rest-server.d.ts +1 -0
  76. package/dist/setup-rest-server.js +52 -0
  77. package/dist/standalone-db.d.ts +58 -0
  78. package/dist/standalone-db.js +142 -0
  79. package/dist/store.d.ts +62 -0
  80. package/dist/store.js +286 -0
  81. package/dist/timescale/query-builder.d.ts +43 -0
  82. package/dist/timescale/query-builder.js +115 -0
  83. package/dist/timescale/timescale-db.d.ts +45 -0
  84. package/dist/timescale/timescale-db.js +84 -0
  85. package/dist/transforms.d.ts +2 -0
  86. package/dist/transforms.js +17 -0
  87. package/dist/types/orm-types.d.ts +153 -0
  88. package/dist/types/orm-types.js +1 -0
  89. package/dist/utils.d.ts +7 -0
  90. package/dist/utils.js +17 -0
  91. package/dist/view-resolver.d.ts +8 -0
  92. package/dist/view-resolver.js +171 -0
  93. package/dist/view.d.ts +11 -0
  94. package/dist/view.js +18 -0
  95. package/package.json +64 -11
  96. package/src/aggregates.ts +109 -0
  97. package/src/{attr.js → attr.ts} +2 -2
  98. package/src/belongs-to.ts +90 -0
  99. package/src/cli.ts +183 -0
  100. package/src/commands.ts +179 -0
  101. package/src/db.ts +232 -0
  102. package/src/exports/db.ts +7 -0
  103. package/src/has-many.ts +92 -0
  104. package/src/hooks.ts +151 -0
  105. package/src/{index.js → index.ts} +12 -2
  106. package/src/main.ts +229 -0
  107. package/src/manage-record.ts +161 -0
  108. package/src/{meta-request.js → meta-request.ts} +17 -14
  109. package/src/migrate.ts +72 -0
  110. package/src/model-property.ts +35 -0
  111. package/src/model.ts +21 -0
  112. package/src/mysql/connection.ts +43 -0
  113. package/src/mysql/migration-generator.ts +337 -0
  114. package/src/mysql/migration-runner.ts +121 -0
  115. package/src/mysql/mysql-db.ts +543 -0
  116. package/src/mysql/query-builder.ts +69 -0
  117. package/src/mysql/schema-introspector.ts +310 -0
  118. package/src/mysql/type-map.ts +42 -0
  119. package/src/orm-request.ts +582 -0
  120. package/src/plural-registry.ts +12 -0
  121. package/src/postgres/connection.ts +48 -0
  122. package/src/postgres/migration-generator.ts +370 -0
  123. package/src/postgres/migration-runner.ts +115 -0
  124. package/src/postgres/postgres-db.ts +616 -0
  125. package/src/postgres/query-builder.ts +148 -0
  126. package/src/postgres/schema-introspector.ts +360 -0
  127. package/src/postgres/type-map.ts +61 -0
  128. package/src/record.ts +186 -0
  129. package/src/relationships.ts +54 -0
  130. package/src/schema-helpers.ts +59 -0
  131. package/src/serializer.ts +161 -0
  132. package/src/setup-rest-server.ts +62 -0
  133. package/src/standalone-db.ts +185 -0
  134. package/src/store.ts +373 -0
  135. package/src/timescale/query-builder.ts +174 -0
  136. package/src/timescale/timescale-db.ts +119 -0
  137. package/src/transforms.ts +20 -0
  138. package/src/types/mysql2.d.ts +49 -0
  139. package/src/types/orm-types.ts +158 -0
  140. package/src/types/pg.d.ts +32 -0
  141. package/src/types/stonyx-cron.d.ts +5 -0
  142. package/src/types/stonyx-events.d.ts +4 -0
  143. package/src/types/stonyx-rest-server.d.ts +16 -0
  144. package/src/types/stonyx-utils.d.ts +33 -0
  145. package/src/types/stonyx.d.ts +21 -0
  146. package/src/utils.ts +22 -0
  147. package/src/view-resolver.ts +211 -0
  148. package/src/view.ts +22 -0
  149. package/.claude/project-structure.md +0 -578
  150. package/.github/workflows/ci.yml +0 -36
  151. package/.github/workflows/publish.yml +0 -143
  152. package/src/belongs-to.js +0 -63
  153. package/src/db.js +0 -80
  154. package/src/has-many.js +0 -61
  155. package/src/main.js +0 -119
  156. package/src/manage-record.js +0 -103
  157. package/src/model-property.js +0 -29
  158. package/src/model.js +0 -9
  159. package/src/orm-request.js +0 -249
  160. package/src/record.js +0 -100
  161. package/src/relationships.js +0 -43
  162. package/src/serializer.js +0 -138
  163. package/src/setup-rest-server.js +0 -57
  164. package/src/store.js +0 -211
  165. package/src/transforms.js +0 -20
  166. package/stonyx-bootstrap.cjs +0 -30
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Standalone JSON database layer for CLI usage.
3
+ *
4
+ * Reads and writes directly to JSON files without requiring the Stonyx
5
+ * bootstrap, ORM init, or any framework dependencies. Supports both
6
+ * single-file and directory modes.
7
+ */
8
+
9
+ import fs from 'fs/promises';
10
+ import path from 'path';
11
+
12
+ interface StandaloneDBOptions {
13
+ dbPath?: string;
14
+ mode?: 'file' | 'directory';
15
+ directory?: string;
16
+ }
17
+
18
+ interface DBRecord {
19
+ id: string | number;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ export default class StandaloneDB {
24
+ readonly mode: 'file' | 'directory';
25
+ readonly dbPath: string;
26
+ readonly directory: string;
27
+
28
+ constructor(options: StandaloneDBOptions = {}) {
29
+ this.mode = options.mode || 'directory';
30
+ this.dbPath = options.dbPath || 'db.json';
31
+ this.directory = options.directory || 'db';
32
+ }
33
+
34
+ /**
35
+ * Resolve the directory path for directory mode.
36
+ */
37
+ getDirPath(): string {
38
+ const dbDir = path.dirname(path.resolve(this.dbPath));
39
+ return path.join(dbDir, this.directory);
40
+ }
41
+
42
+ /**
43
+ * List available collections by inspecting either the db.json keys
44
+ * or the files in the db directory.
45
+ */
46
+ async getCollections(): Promise<string[]> {
47
+ if (this.mode === 'directory') {
48
+ const dirPath = this.getDirPath();
49
+
50
+ try {
51
+ const files = await fs.readdir(dirPath);
52
+ return files
53
+ .filter(f => f.endsWith('.json'))
54
+ .map(f => f.replace('.json', ''));
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ // File mode -- read db.json and return its top-level keys
61
+ try {
62
+ const data = await this._readJSON(this.dbPath) as Record<string, unknown>;
63
+ return Object.keys(data).filter(key => Array.isArray(data[key]));
64
+ } catch {
65
+ return [];
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Read all records for a collection.
71
+ */
72
+ async readCollection(collection: string): Promise<DBRecord[]> {
73
+ if (this.mode === 'directory') {
74
+ const filePath = path.join(this.getDirPath(), `${collection}.json`);
75
+ return this._readJSON(filePath) as Promise<DBRecord[]>;
76
+ }
77
+
78
+ const data = await this._readJSON(this.dbPath) as Record<string, DBRecord[]>;
79
+ return data[collection] || [];
80
+ }
81
+
82
+ /**
83
+ * Write all records for a collection.
84
+ */
85
+ async writeCollection(collection: string, records: DBRecord[]): Promise<void> {
86
+ if (this.mode === 'directory') {
87
+ const dirPath = this.getDirPath();
88
+ await fs.mkdir(dirPath, { recursive: true });
89
+
90
+ const filePath = path.join(dirPath, `${collection}.json`);
91
+ await this._writeJSON(filePath, records);
92
+ return;
93
+ }
94
+
95
+ // File mode -- read full db, update collection, write back
96
+ let data: Record<string, unknown>;
97
+
98
+ try {
99
+ data = await this._readJSON(this.dbPath) as Record<string, unknown>;
100
+ } catch {
101
+ data = {};
102
+ }
103
+
104
+ data[collection] = records;
105
+ await this._writeJSON(this.dbPath, data);
106
+ }
107
+
108
+ /**
109
+ * Get a single record by id.
110
+ */
111
+ async get(collection: string, id: string | number): Promise<DBRecord | null> {
112
+ const records = await this.readCollection(collection);
113
+ const numericId = Number(id);
114
+
115
+ return records.find(r =>
116
+ r.id === id || r.id === numericId
117
+ ) || null;
118
+ }
119
+
120
+ /**
121
+ * List all records in a collection.
122
+ */
123
+ async list(collection: string): Promise<DBRecord[]> {
124
+ return this.readCollection(collection);
125
+ }
126
+
127
+ /**
128
+ * Create a new record. Auto-assigns an integer id if none provided.
129
+ */
130
+ async create(collection: string, data: DBRecord): Promise<DBRecord> {
131
+ const records = await this.readCollection(collection);
132
+
133
+ 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;
140
+ }
141
+
142
+ // Check for duplicate id
143
+ const existing = records.find(r => r.id === data.id);
144
+ if (existing) {
145
+ throw new Error(`Record with id ${data.id} already exists in '${collection}'`);
146
+ }
147
+
148
+ records.push(data);
149
+ await this.writeCollection(collection, records);
150
+
151
+ return data;
152
+ }
153
+
154
+ /**
155
+ * Delete a record by id.
156
+ */
157
+ async delete(collection: string, id: string | number): Promise<DBRecord> {
158
+ const records = await this.readCollection(collection);
159
+ const numericId = Number(id);
160
+
161
+ const index = records.findIndex(r =>
162
+ r.id === id || r.id === numericId
163
+ );
164
+
165
+ if (index === -1) {
166
+ throw new Error(`Record with id '${id}' not found in '${collection}'`);
167
+ }
168
+
169
+ const [removed] = records.splice(index, 1);
170
+ await this.writeCollection(collection, records);
171
+
172
+ return removed;
173
+ }
174
+
175
+ // -- Private helpers --
176
+
177
+ private async _readJSON(filePath: string): Promise<unknown> {
178
+ const content = await fs.readFile(filePath, 'utf-8');
179
+ return JSON.parse(content);
180
+ }
181
+
182
+ private async _writeJSON(filePath: string, data: unknown): Promise<void> {
183
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
184
+ }
185
+ }
package/src/store.ts ADDED
@@ -0,0 +1,373 @@
1
+ import Orm, { relationships } from '@stonyx/orm';
2
+ import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry } from './relationships.js';
3
+ import ViewResolver from './view-resolver.js';
4
+
5
+ interface UnloadOptions {
6
+ includeChildren?: boolean;
7
+ [key: string]: unknown;
8
+ }
9
+
10
+ interface UnloadQueueItem {
11
+ record: StoreRecord;
12
+ modelName: string;
13
+ recordId: unknown;
14
+ isRoot?: boolean;
15
+ depth?: number;
16
+ }
17
+
18
+ interface ChildInfo {
19
+ childRecord: StoreRecord;
20
+ relationshipKey: string;
21
+ type: string;
22
+ }
23
+
24
+ interface StoreRecord {
25
+ __model: { __name: string; [key: string]: unknown };
26
+ __data: Record<string, unknown>;
27
+ __relationships: Record<string, unknown>;
28
+ id: unknown;
29
+ clean(): void;
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ function isStoreRecord(value: unknown): value is StoreRecord {
34
+ return typeof value === 'object' && value !== null && '__data' in value;
35
+ }
36
+
37
+ export default class Store {
38
+ static instance: Store | undefined;
39
+
40
+ data: Map<string, Map<number | string, unknown>> = new Map();
41
+
42
+ /**
43
+ * Set by Orm during init — resolves memory flag for a model name.
44
+ */
45
+ _memoryResolver: ((modelName: string) => boolean) | null = null;
46
+
47
+ /**
48
+ * Set by Orm during init — reference to the SQL adapter instance for on-demand queries.
49
+ */
50
+ _sqlDb: { findRecord(modelName: string, id: unknown): Promise<unknown>; findAll(modelName: string, conditions?: Record<string, unknown>): Promise<unknown[]> } | null = null;
51
+
52
+ constructor() {
53
+ if (Store.instance) return Store.instance;
54
+ Store.instance = this;
55
+
56
+ this.data = new Map();
57
+ }
58
+
59
+ /**
60
+ * Synchronous memory-only access.
61
+ * Returns the record if it exists in the in-memory store, undefined otherwise.
62
+ * Does NOT query the database. For memory:false models, use find() instead.
63
+ */
64
+ get(key: string): Map<number | string, unknown> | undefined;
65
+ get(key: string, id: number | string): unknown;
66
+ get(key: string, id?: number | string): Map<number | string, unknown> | unknown | undefined {
67
+ if (!id) return this.data.get(key);
68
+
69
+ return this.data.get(key)?.get(id);
70
+ }
71
+
72
+ /**
73
+ * Async authoritative read. Always queries the SQL database for memory: false models.
74
+ * For memory: true models, returns from store (already loaded on boot).
75
+ */
76
+ async find(modelName: string, id: number | string): Promise<unknown> {
77
+ // For views in non-SQL mode, use view resolver
78
+ if (Orm.instance?.isView?.(modelName) && !this._sqlDb) {
79
+ const resolver = new ViewResolver(modelName);
80
+ return resolver.resolveOne(id);
81
+ }
82
+
83
+ // For memory: true models, the store is authoritative
84
+ if (this._isMemoryModel(modelName)) {
85
+ return this.get(modelName, id);
86
+ }
87
+
88
+ // For memory: false models, always query the SQL database
89
+ if (this._sqlDb) {
90
+ return this._sqlDb.findRecord(modelName, id);
91
+ }
92
+
93
+ // Fallback to store (JSON mode or no SQL adapter)
94
+ return this.get(modelName, id);
95
+ }
96
+
97
+ /**
98
+ * Async read for all records of a model. Always queries MySQL for memory: false models.
99
+ * For memory: true models, returns from store.
100
+ */
101
+ async findAll(modelName: string, conditions?: Record<string, unknown>): Promise<unknown[]> {
102
+ // For views in non-SQL mode, use view resolver
103
+ if (Orm.instance?.isView?.(modelName) && !this._sqlDb) {
104
+ const resolver = new ViewResolver(modelName);
105
+ const records = await resolver.resolveAll();
106
+
107
+ if (!conditions || Object.keys(conditions).length === 0) return records;
108
+
109
+ return records.filter((record: unknown) =>
110
+ Object.entries(conditions).every(([key, value]) => isStoreRecord(record) && record.__data[key] === value)
111
+ );
112
+ }
113
+
114
+ // For memory: true models without conditions, return from store
115
+ if (this._isMemoryModel(modelName) && !conditions) {
116
+ const modelStore = this.get(modelName);
117
+ return modelStore ? Array.from(modelStore.values()) : [];
118
+ }
119
+
120
+ // For memory: false models (or filtered queries), always query the SQL database
121
+ if (this._sqlDb) {
122
+ return this._sqlDb.findAll(modelName, conditions);
123
+ }
124
+
125
+ // Fallback to store (JSON mode) — apply conditions in-memory if provided
126
+ const modelStore = this.get(modelName);
127
+ if (!modelStore) return [];
128
+
129
+ const records = Array.from(modelStore.values());
130
+
131
+ if (!conditions || Object.keys(conditions).length === 0) return records;
132
+
133
+ return records.filter((record: unknown) =>
134
+ Object.entries(conditions).every(([key, value]) => isStoreRecord(record) && record.__data[key] === value)
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Async query — always hits MySQL, never reads from memory cache.
140
+ * Use for complex queries, aggregations, or when you need guaranteed freshness.
141
+ */
142
+ async query(modelName: string, conditions: Record<string, unknown> = {}): Promise<unknown[]> {
143
+ if (this._sqlDb) {
144
+ return this._sqlDb.findAll(modelName, conditions);
145
+ }
146
+
147
+ // Fallback: filter in-memory store
148
+ const modelStore = this.get(modelName);
149
+ if (!modelStore) return [];
150
+
151
+ const records = Array.from(modelStore.values());
152
+
153
+ if (Object.keys(conditions).length === 0) return records;
154
+
155
+ return records.filter((record: unknown) =>
156
+ Object.entries(conditions).every(([key, value]) => isStoreRecord(record) && record.__data[key] === value)
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Check if a model is configured for in-memory storage.
162
+ * @private
163
+ */
164
+ private _isMemoryModel(modelName: string): boolean {
165
+ if (this._memoryResolver) return this._memoryResolver(modelName);
166
+ return false; // default to non-memory if resolver not set yet
167
+ }
168
+
169
+ set(key: string, value: Map<number | string, unknown>): void {
170
+ this.data.set(key, value);
171
+ }
172
+
173
+ remove(key: string, id?: number | string): void {
174
+ // Guard: read-only views cannot have records removed
175
+ if (Orm.instance?.isView?.(key)) {
176
+ throw new Error(`Cannot remove records from read-only view '${key}'`);
177
+ }
178
+
179
+ if (id) return this.unloadRecord(key, id);
180
+
181
+ this.unloadAllRecords(key);
182
+ }
183
+
184
+ unloadRecord(model: string, id: unknown, options: UnloadOptions = {}): void {
185
+ const modelStore = this.data.get(model);
186
+
187
+ if (!modelStore) {
188
+ console.warn(`[Store] Cannot unload record: model "${model}" not found in store — ensure the model is registered before unloading`);
189
+ return;
190
+ }
191
+
192
+ if (typeof id !== 'string' && typeof id !== 'number') return;
193
+ const raw = modelStore.get(id);
194
+ if (!raw || !isStoreRecord(raw)) {
195
+ console.warn(`[Store] Cannot unload record: ${model}:${id} not found in store — it may have already been unloaded`);
196
+ return;
197
+ }
198
+ const record = raw;
199
+
200
+ const { toUnload, visited } = options.includeChildren
201
+ ? this._buildUnloadQueue(record, options)
202
+ : { toUnload: [{ record, modelName: model, recordId: id }] as UnloadQueueItem[], visited: new Set([`${model}:${id}`]) };
203
+
204
+ for (const item of toUnload.reverse()) {
205
+ const { record: recordToUnload, modelName, recordId } = item;
206
+
207
+ this._removeFromHasManyArrays(modelName, recordId, visited);
208
+ this._nullifyBelongsToReferences(modelName, recordId, visited);
209
+ this._cleanupRelationshipRegistries(modelName, recordId);
210
+ recordToUnload.clean();
211
+
212
+ this.data.get(modelName)?.delete(recordId as string | number);
213
+ }
214
+ }
215
+
216
+ unloadAllRecords(model: string, options: UnloadOptions = {}): void {
217
+ const modelStore = this.data.get(model);
218
+
219
+ if (!modelStore) {
220
+ console.warn(`[Store] Cannot unload all records: model "${model}" not found in store — ensure the model is registered before unloading`);
221
+ return;
222
+ }
223
+
224
+ const recordIds = Array.from(modelStore.keys());
225
+
226
+ for (const id of recordIds) {
227
+ if (modelStore.has(id)) {
228
+ this.unloadRecord(model, id, options);
229
+ }
230
+ }
231
+
232
+ for (const relationshipType of TYPES) {
233
+ const reg = relationships.get(relationshipType);
234
+ if (reg instanceof Map) reg.delete(model);
235
+ }
236
+ }
237
+
238
+ private _removeFromHasManyArrays(modelName: string, recordId: unknown, visited: Set<string>): void {
239
+ const hasManyRegistry = getHasManyRegistry();
240
+
241
+ for (const [sourceModel, targetModels] of hasManyRegistry) {
242
+ const targetModelMap = targetModels.get(modelName);
243
+ if (!targetModelMap) continue;
244
+
245
+ for (const [sourceRecordId, hasManyArray] of targetModelMap) {
246
+ const sourceKey = `${sourceModel}:${sourceRecordId}`;
247
+
248
+ // Don't modify arrays of records being deleted
249
+ if (visited.has(sourceKey)) continue;
250
+
251
+ const index = hasManyArray.findIndex(r => r && isStoreRecord(r) && r.id === recordId);
252
+ if (index !== -1) hasManyArray.splice(index, 1);
253
+ }
254
+ }
255
+ }
256
+
257
+ private _nullifyBelongsToReferences(modelName: string, recordId: unknown, visited: Set<string>): void {
258
+ const belongsToRegistry = getBelongsToRegistry();
259
+
260
+ for (const [sourceModel, targetModels] of belongsToRegistry) {
261
+ const targetModelMap = targetModels.get(modelName);
262
+ if (!targetModelMap) continue;
263
+
264
+ for (const [sourceRecordId, belongsToRecord] of targetModelMap) {
265
+ if (belongsToRecord && isStoreRecord(belongsToRecord) && belongsToRecord.id === recordId) {
266
+ const sourceKey = `${sourceModel}:${sourceRecordId}`;
267
+
268
+ if (visited.has(sourceKey)) continue;
269
+ targetModelMap.set(sourceRecordId, null);
270
+
271
+ if (typeof sourceRecordId !== 'string' && typeof sourceRecordId !== 'number') continue;
272
+ const sourceRaw = this.get(sourceModel, sourceRecordId);
273
+ if (!sourceRaw || !isStoreRecord(sourceRaw)) continue;
274
+ if (sourceRaw.__relationships) {
275
+ for (const [key, value] of Object.entries(sourceRaw.__relationships)) {
276
+ if (value && isStoreRecord(value) && value.id === recordId) {
277
+ sourceRaw.__relationships[key] = null;
278
+ }
279
+ }
280
+ }
281
+ }
282
+ }
283
+ }
284
+ }
285
+
286
+ private _cleanupRelationshipRegistries(modelName: string, recordId: unknown): void {
287
+ const hasManyMap = getHasManyRegistry().get(modelName);
288
+ if (hasManyMap) {
289
+ for (const [, recordMap] of hasManyMap) recordMap.delete(recordId);
290
+ }
291
+
292
+ const belongsToMap = getBelongsToRegistry().get(modelName);
293
+ if (belongsToMap) {
294
+ for (const [, recordMap] of belongsToMap) recordMap.delete(recordId);
295
+ }
296
+
297
+ const pendingMap = getPendingRegistry().get(modelName);
298
+ if (pendingMap) pendingMap.delete(recordId);
299
+ }
300
+
301
+ /**
302
+ * Extracts hasMany and non-bidirectional belongsTo children from a record
303
+ * @private
304
+ */
305
+ private _getChildren(record: StoreRecord): ChildInfo[] {
306
+ const children: ChildInfo[] = [];
307
+
308
+ if (!record.__relationships) return children;
309
+
310
+ for (const [key, value] of Object.entries(record.__relationships)) {
311
+ // hasMany children - always include
312
+ if (Array.isArray(value)) {
313
+ for (const childRecord of value) {
314
+ if (childRecord && isStoreRecord(childRecord)) children.push({ childRecord, relationshipKey: key, type: 'hasMany' });
315
+ }
316
+ } else if (value && isStoreRecord(value) && value.__model && !this._isBidirectionalRelationship(
317
+ record.__model.__name,
318
+ (value as StoreRecord).__model.__name
319
+ )) {
320
+ children.push({ childRecord: value as StoreRecord, relationshipKey: key, type: 'belongsTo' });
321
+ }
322
+ }
323
+
324
+ return children;
325
+ }
326
+
327
+ private _isBidirectionalRelationship(sourceModel: string, targetModel: string): boolean {
328
+ const inverseMap = getHasManyRegistry().get(targetModel)?.get(sourceModel);
329
+
330
+ return !!inverseMap && inverseMap.size > 0;
331
+ }
332
+
333
+ private _buildUnloadQueue(record: StoreRecord, options: UnloadOptions): { toUnload: UnloadQueueItem[]; visited: Set<string> } {
334
+ const visited = new Set<string>();
335
+ const toUnload: UnloadQueueItem[] = [];
336
+ const queue: UnloadQueueItem[] = [{
337
+ record,
338
+ modelName: record.__model.__name,
339
+ recordId: record.id,
340
+ isRoot: true,
341
+ depth: 0
342
+ }];
343
+
344
+ while (queue.length > 0) {
345
+ const item = queue.shift();
346
+ if (!item) break;
347
+ const key = `${item.modelName}:${item.recordId}`;
348
+
349
+ if (visited.has(key)) continue;
350
+ visited.add(key);
351
+
352
+ toUnload.push(item);
353
+
354
+ // Add children to queue if includeChildren is enabled
355
+ if (options.includeChildren) {
356
+ const children = this._getChildren(item.record);
357
+ for (const { childRecord } of children) {
358
+ if (childRecord) {
359
+ queue.push({
360
+ record: childRecord,
361
+ modelName: childRecord.__model.__name,
362
+ recordId: childRecord.id,
363
+ isRoot: false,
364
+ depth: (item.depth ?? 0) + 1
365
+ });
366
+ }
367
+ }
368
+ }
369
+ }
370
+
371
+ return { toUnload, visited };
372
+ }
373
+ }