@stonyx/orm 0.2.1-beta.8 → 0.2.1-beta.80

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/src/store.js CHANGED
@@ -1,5 +1,6 @@
1
- import { relationships } from '@stonyx/orm';
1
+ import Orm, { relationships } from '@stonyx/orm';
2
2
  import { TYPES } from './relationships.js';
3
+ import ViewResolver from './view-resolver.js';
3
4
 
4
5
  export default class Store {
5
6
  constructor() {
@@ -9,17 +10,145 @@ export default class Store {
9
10
  this.data = new Map();
10
11
  }
11
12
 
13
+ /**
14
+ * Synchronous memory-only access.
15
+ * Returns the record if it exists in the in-memory store, undefined otherwise.
16
+ * Does NOT query the database. For memory:false models, use find() instead.
17
+ */
12
18
  get(key, id) {
13
19
  if (!id) return this.data.get(key);
14
20
 
15
21
  return this.data.get(key)?.get(id);
16
22
  }
17
23
 
24
+ /**
25
+ * Async authoritative read. Always queries MySQL for memory: false models.
26
+ * For memory: true models, returns from store (already loaded on boot).
27
+ * @param {string} modelName - The model name
28
+ * @param {string|number} id - The record ID
29
+ * @returns {Promise<Record|undefined>}
30
+ */
31
+ async find(modelName, id) {
32
+ // For views in non-MySQL mode, use view resolver
33
+ if (Orm.instance?.isView?.(modelName) && !this._mysqlDb) {
34
+ const resolver = new ViewResolver(modelName);
35
+ return resolver.resolveOne(id);
36
+ }
37
+
38
+ // For memory: true models, the store is authoritative
39
+ if (this._isMemoryModel(modelName)) {
40
+ return this.get(modelName, id);
41
+ }
42
+
43
+ // For memory: false models, always query MySQL
44
+ if (this._mysqlDb) {
45
+ return this._mysqlDb.findRecord(modelName, id);
46
+ }
47
+
48
+ // Fallback to store (JSON mode or no MySQL)
49
+ return this.get(modelName, id);
50
+ }
51
+
52
+ /**
53
+ * Async read for all records of a model. Always queries MySQL for memory: false models.
54
+ * For memory: true models, returns from store.
55
+ * @param {string} modelName - The model name
56
+ * @param {Object} [conditions] - Optional WHERE conditions
57
+ * @returns {Promise<Record[]>}
58
+ */
59
+ async findAll(modelName, conditions) {
60
+ // For views in non-MySQL mode, use view resolver
61
+ if (Orm.instance?.isView?.(modelName) && !this._mysqlDb) {
62
+ const resolver = new ViewResolver(modelName);
63
+ const records = await resolver.resolveAll();
64
+
65
+ if (!conditions || Object.keys(conditions).length === 0) return records;
66
+
67
+ return records.filter(record =>
68
+ Object.entries(conditions).every(([key, value]) => record.__data[key] === value)
69
+ );
70
+ }
71
+
72
+ // For memory: true models without conditions, return from store
73
+ if (this._isMemoryModel(modelName) && !conditions) {
74
+ const modelStore = this.get(modelName);
75
+ return modelStore ? Array.from(modelStore.values()) : [];
76
+ }
77
+
78
+ // For memory: false models (or filtered queries), always query MySQL
79
+ if (this._mysqlDb) {
80
+ return this._mysqlDb.findAll(modelName, conditions);
81
+ }
82
+
83
+ // Fallback to store (JSON mode) — apply conditions in-memory if provided
84
+ const modelStore = this.get(modelName);
85
+ if (!modelStore) return [];
86
+
87
+ const records = Array.from(modelStore.values());
88
+
89
+ if (!conditions || Object.keys(conditions).length === 0) return records;
90
+
91
+ return records.filter(record =>
92
+ Object.entries(conditions).every(([key, value]) => record.__data[key] === value)
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Async query — always hits MySQL, never reads from memory cache.
98
+ * Use for complex queries, aggregations, or when you need guaranteed freshness.
99
+ * @param {string} modelName - The model name
100
+ * @param {Object} conditions - WHERE conditions
101
+ * @returns {Promise<Record[]>}
102
+ */
103
+ async query(modelName, conditions = {}) {
104
+ if (this._mysqlDb) {
105
+ return this._mysqlDb.findAll(modelName, conditions);
106
+ }
107
+
108
+ // Fallback: filter in-memory store
109
+ const modelStore = this.get(modelName);
110
+ if (!modelStore) return [];
111
+
112
+ const records = Array.from(modelStore.values());
113
+
114
+ if (Object.keys(conditions).length === 0) return records;
115
+
116
+ return records.filter(record =>
117
+ Object.entries(conditions).every(([key, value]) => record.__data[key] === value)
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Set by Orm during init — resolves memory flag for a model name.
123
+ * @type {Function|null}
124
+ */
125
+ _memoryResolver = null;
126
+
127
+ /**
128
+ * Set by Orm during init — reference to the MysqlDB instance for on-demand queries.
129
+ * @type {MysqlDB|null}
130
+ */
131
+ _mysqlDb = null;
132
+
133
+ /**
134
+ * Check if a model is configured for in-memory storage.
135
+ * @private
136
+ */
137
+ _isMemoryModel(modelName) {
138
+ if (this._memoryResolver) return this._memoryResolver(modelName);
139
+ return false; // default to non-memory if resolver not set yet
140
+ }
141
+
18
142
  set(key, value) {
19
143
  this.data.set(key, value);
20
144
  }
21
145
 
22
146
  remove(key, id) {
147
+ // Guard: read-only views cannot have records removed
148
+ if (Orm.instance?.isView?.(key)) {
149
+ throw new Error(`Cannot remove records from read-only view '${key}'`);
150
+ }
151
+
23
152
  if (id) return this.unloadRecord(key, id);
24
153
 
25
154
  this.unloadAllRecords(key);
@@ -0,0 +1,183 @@
1
+ import Orm, { createRecord, store } from '@stonyx/orm';
2
+ import { AggregateProperty } from './aggregates.js';
3
+ import { get } from '@stonyx/utils/object';
4
+
5
+ export default class ViewResolver {
6
+ constructor(viewName) {
7
+ this.viewName = viewName;
8
+ }
9
+
10
+ async resolveAll() {
11
+ const orm = Orm.instance;
12
+ const { modelClass: viewClass } = orm.getRecordClasses(this.viewName);
13
+
14
+ if (!viewClass) return [];
15
+
16
+ const source = viewClass.source;
17
+ if (!source) return [];
18
+
19
+ const sourceRecords = await store.findAll(source);
20
+ if (!sourceRecords || sourceRecords.length === 0) {
21
+ return [];
22
+ }
23
+
24
+ const resolveMap = viewClass.resolve || {};
25
+ const viewInstance = new viewClass(this.viewName);
26
+ const aggregateFields = {};
27
+ const regularFields = {};
28
+
29
+ // Categorize fields on the view instance
30
+ for (const [key, value] of Object.entries(viewInstance)) {
31
+ if (key.startsWith('__')) continue;
32
+ if (key === 'id') continue;
33
+
34
+ if (value instanceof AggregateProperty) {
35
+ aggregateFields[key] = value;
36
+ } else if (typeof value !== 'function') {
37
+ // Regular attr or direct value — not a relationship handler
38
+ regularFields[key] = value;
39
+ }
40
+ }
41
+
42
+ const groupByField = viewClass.groupBy;
43
+
44
+ if (groupByField) {
45
+ return this._resolveGroupBy(sourceRecords, groupByField, aggregateFields, regularFields, resolveMap, viewClass);
46
+ }
47
+
48
+ return this._resolvePerRecord(sourceRecords, aggregateFields, regularFields, resolveMap, viewClass);
49
+ }
50
+
51
+ _resolvePerRecord(sourceRecords, aggregateFields, regularFields, resolveMap, viewClass) {
52
+ const results = [];
53
+
54
+ for (const sourceRecord of sourceRecords) {
55
+ const rawData = { id: sourceRecord.id };
56
+
57
+ // Compute aggregate fields from source record's relationships
58
+ for (const [key, aggProp] of Object.entries(aggregateFields)) {
59
+ const relatedRecords = sourceRecord.__relationships?.[aggProp.relationship]
60
+ || sourceRecord[aggProp.relationship];
61
+ const relArray = Array.isArray(relatedRecords) ? relatedRecords : [];
62
+ rawData[key] = aggProp.compute(relArray);
63
+ }
64
+
65
+ // Apply resolve map entries
66
+ for (const [key, resolver] of Object.entries(resolveMap)) {
67
+ if (typeof resolver === 'function') {
68
+ rawData[key] = resolver(sourceRecord);
69
+ } else if (typeof resolver === 'string') {
70
+ rawData[key] = get(sourceRecord.__data || sourceRecord, resolver)
71
+ ?? get(sourceRecord, resolver);
72
+ }
73
+ }
74
+
75
+ // Map regular attr fields from source record if not already set
76
+ for (const key of Object.keys(regularFields)) {
77
+ if (rawData[key] !== undefined) continue;
78
+
79
+ const sourceValue = sourceRecord.__data?.[key] ?? sourceRecord[key];
80
+ if (sourceValue !== undefined) {
81
+ rawData[key] = sourceValue;
82
+ }
83
+ }
84
+
85
+ // Set belongsTo source relationship
86
+ const viewInstanceForRel = new viewClass(this.viewName);
87
+ for (const [key, value] of Object.entries(viewInstanceForRel)) {
88
+ if (typeof value === 'function' && key !== 'id') {
89
+ // This is a relationship handler — pass the source record id
90
+ rawData[key] = sourceRecord.id;
91
+ }
92
+ }
93
+
94
+ // Clear existing record from store to allow re-resolution
95
+ const viewStore = store.get(this.viewName);
96
+ if (viewStore?.has(rawData.id)) {
97
+ viewStore.delete(rawData.id);
98
+ }
99
+
100
+ const record = createRecord(this.viewName, rawData, { isDbRecord: true });
101
+ results.push(record);
102
+ }
103
+
104
+ return results;
105
+ }
106
+
107
+ _resolveGroupBy(sourceRecords, groupByField, aggregateFields, regularFields, resolveMap, viewClass) {
108
+ // Group source records by the groupBy field value
109
+ const groups = new Map();
110
+ for (const record of sourceRecords) {
111
+ const key = record.__data?.[groupByField] ?? record[groupByField];
112
+ if (!groups.has(key)) {
113
+ groups.set(key, []);
114
+ }
115
+ groups.get(key).push(record);
116
+ }
117
+
118
+ const results = [];
119
+
120
+ for (const [groupKey, groupRecords] of groups) {
121
+ const rawData = { id: groupKey };
122
+
123
+ // Compute aggregate fields
124
+ for (const [key, aggProp] of Object.entries(aggregateFields)) {
125
+ if (aggProp.relationship === undefined) {
126
+ // Field-level aggregate — compute over group records directly
127
+ rawData[key] = aggProp.compute(groupRecords);
128
+ } else {
129
+ // Relationship aggregate — flatten related records across all group members
130
+ const allRelated = [];
131
+ for (const record of groupRecords) {
132
+ const relatedRecords = record.__relationships?.[aggProp.relationship]
133
+ || record[aggProp.relationship];
134
+ if (Array.isArray(relatedRecords)) {
135
+ allRelated.push(...relatedRecords);
136
+ }
137
+ }
138
+ rawData[key] = aggProp.compute(allRelated);
139
+ }
140
+ }
141
+
142
+ // Apply resolve map entries — functions receive the group array
143
+ for (const [key, resolver] of Object.entries(resolveMap)) {
144
+ if (typeof resolver === 'function') {
145
+ rawData[key] = resolver(groupRecords);
146
+ } else if (typeof resolver === 'string') {
147
+ // String path — take value from first record in group
148
+ const first = groupRecords[0];
149
+ rawData[key] = get(first.__data || first, resolver)
150
+ ?? get(first, resolver);
151
+ }
152
+ }
153
+
154
+ // Map regular attr fields from first record if not already set
155
+ for (const key of Object.keys(regularFields)) {
156
+ if (rawData[key] !== undefined) continue;
157
+ const first = groupRecords[0];
158
+ const sourceValue = first.__data?.[key] ?? first[key];
159
+ if (sourceValue !== undefined) {
160
+ rawData[key] = sourceValue;
161
+ }
162
+ }
163
+
164
+ // Clear existing record from store to allow re-resolution
165
+ const viewStore = store.get(this.viewName);
166
+ if (viewStore?.has(rawData.id)) {
167
+ viewStore.delete(rawData.id);
168
+ }
169
+
170
+ const record = createRecord(this.viewName, rawData, { isDbRecord: true });
171
+ results.push(record);
172
+ }
173
+
174
+ return results;
175
+ }
176
+
177
+ async resolveOne(id) {
178
+ const all = await this.resolveAll();
179
+ return all.find(record => {
180
+ return record.id === id || record.id == id;
181
+ });
182
+ }
183
+ }
package/src/view.js ADDED
@@ -0,0 +1,21 @@
1
+ import { attr } from '@stonyx/orm';
2
+
3
+ export default class View {
4
+ static memory = false;
5
+ static readOnly = true;
6
+ static pluralName = undefined;
7
+ static source = undefined;
8
+ static groupBy = undefined;
9
+ static resolve = undefined;
10
+
11
+ id = attr('number');
12
+
13
+ constructor(name) {
14
+ this.__name = name;
15
+
16
+ // Enforce readOnly — cannot be overridden to false
17
+ if (this.constructor.readOnly !== true) {
18
+ throw new Error(`View '${name}' cannot override readOnly to false`);
19
+ }
20
+ }
21
+ }
@@ -1,44 +0,0 @@
1
- # Stonyx Code Style Rules
2
-
3
- Strict prettier/eslint rules to apply across all Stonyx projects. These will be formalized into an ESLint/Prettier config once enough patterns are collected.
4
-
5
- ---
6
-
7
- ## Rules
8
-
9
- ### 1. Destructure config objects in function signatures
10
-
11
- When a function receives a config/options object and only uses specific properties, destructure them in the function signature rather than accessing them via dot notation in the body.
12
-
13
- **Bad:**
14
- ```javascript
15
- export async function getPool(mysqlConfig) {
16
- pool = mysql.createPool({
17
- host: mysqlConfig.host,
18
- port: mysqlConfig.port,
19
- user: mysqlConfig.user,
20
- password: mysqlConfig.password,
21
- database: mysqlConfig.database,
22
- connectionLimit: mysqlConfig.connectionLimit,
23
- // ...
24
- });
25
- }
26
- ```
27
-
28
- **Good:**
29
- ```javascript
30
- export async function getPool({ host, port, user, password, database, connectionLimit }) {
31
- pool = mysql.createPool({
32
- host,
33
- port,
34
- user,
35
- password,
36
- database,
37
- connectionLimit,
38
- // ...
39
- });
40
- }
41
- ```
42
-
43
- **Source:** PR #14, `src/mysql/connection.js`
44
- **ESLint rule (candidate):** `prefer-destructuring` (with custom config for function parameters)
package/.claude/hooks.md DELETED
@@ -1,250 +0,0 @@
1
- # Middleware Hooks System
2
-
3
- The ORM provides a powerful middleware-based hook system that allows custom logic before and after CRUD operations. **Before hooks can halt operations** by returning a value.
4
-
5
- ## Architecture
6
-
7
- **Hook Registry**: [src/hooks.js](src/hooks.js) - Stores before/after hooks in Maps
8
- **Integration**: [src/orm-request.js](src/orm-request.js) - `_withHooks()` wrapper executes hooks
9
- **Exports**: [src/index.js](src/index.js) - Exports `beforeHook`, `afterHook`, `clearHook`, `clearAllHooks`
10
-
11
- ## API
12
-
13
- ### `beforeHook(operation, model, handler)`
14
-
15
- Register a before hook that runs before the operation executes.
16
-
17
- ```javascript
18
- import { beforeHook } from '@stonyx/orm';
19
-
20
- beforeHook('create', 'animal', (context) => {
21
- // Validate, transform, authorize...
22
- if (invalid) {
23
- return 400; // Halt with status code
24
- }
25
- // Return undefined to continue
26
- });
27
- ```
28
-
29
- **Handler return values:**
30
- - `undefined` / no return - Operation continues
31
- - **Any other value** - Halts operation and returns that value:
32
- - Integer (e.g., `403`) - HTTP status code
33
- - Object - JSON response body
34
-
35
- **Returns:** Unregister function
36
-
37
- ### `afterHook(operation, model, handler)`
38
-
39
- Register an after hook that runs after the operation completes.
40
-
41
- ```javascript
42
- import { afterHook } from '@stonyx/orm';
43
-
44
- afterHook('update', 'animal', (context) => {
45
- console.log(`Updated animal ${context.record.id}`);
46
- // After hooks cannot halt (operation already complete)
47
- });
48
- ```
49
-
50
- **Returns:** Unregister function
51
-
52
- ### `clearHook(operation, model, [type])`
53
-
54
- Clear registered hooks for a specific operation:model.
55
-
56
- ```javascript
57
- import { clearHook } from '@stonyx/orm';
58
-
59
- clearHook('create', 'animal'); // Clear both before and after
60
- clearHook('create', 'animal', 'before'); // Clear only before hooks
61
- clearHook('create', 'animal', 'after'); // Clear only after hooks
62
- ```
63
-
64
- ### `clearAllHooks()`
65
-
66
- Clear all registered hooks (useful for testing).
67
-
68
- ```javascript
69
- import { clearAllHooks } from '@stonyx/orm';
70
-
71
- afterEach(() => {
72
- clearAllHooks();
73
- });
74
- ```
75
-
76
- ## Operations
77
-
78
- - `list` - GET collection (`/animals`)
79
- - `get` - GET single record (`/animals/1`)
80
- - `create` - POST new record (`/animals`)
81
- - `update` - PATCH existing record (`/animals/1`)
82
- - `delete` - DELETE record (`/animals/1`)
83
-
84
- ## Context Object
85
-
86
- Each hook receives a context object:
87
-
88
- ```javascript
89
- {
90
- model: 'animal', // Model name
91
- operation: 'create', // Operation type
92
- request, // Express request object
93
- params, // URL params (e.g., { id: 5 })
94
- body, // Request body (POST/PATCH)
95
- query, // Query parameters
96
- state, // Request state (includes filter for access control)
97
-
98
- // For update/delete operations:
99
- oldState, // Deep copy of record BEFORE operation
100
-
101
- // For after hooks only:
102
- response, // Handler response
103
- record, // Affected record (create/update/get)
104
- records, // All records (list)
105
- recordId, // Record ID (delete only, since record no longer exists)
106
- }
107
- ```
108
-
109
- **Notes:**
110
- - `oldState` is captured via `JSON.parse(JSON.stringify())` before operation executes
111
- - For delete operations, `recordId` is available since the record may no longer exist
112
- - `oldState` enables precise field-level change detection
113
-
114
- ## Implementation Details
115
-
116
- **Hook Wrapper** (`src/orm-request.js`):
117
-
118
- ```javascript
119
- _withHooks(operation, handler) {
120
- return async (request, state) => {
121
- const context = { model, operation, request, params, body, query, state };
122
-
123
- // Capture old state for update/delete
124
- if (operation === 'update' || operation === 'delete') {
125
- const existingRecord = store.get(this.model, getId(request.params));
126
- if (existingRecord) {
127
- context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
128
- }
129
- }
130
-
131
- // Run before hooks sequentially (can halt by returning a value)
132
- for (const hook of getBeforeHooks(operation, this.model)) {
133
- const result = await hook(context);
134
- if (result !== undefined) {
135
- return result; // Halt - return status/response
136
- }
137
- }
138
-
139
- // Execute main handler
140
- const response = await handler(request, state);
141
-
142
- // Enrich context for after hooks
143
- context.response = response;
144
- context.record = /* fetched from store */;
145
- context.records = /* for list operations */;
146
- context.recordId = /* for delete operations */;
147
-
148
- // Run after hooks sequentially
149
- for (const hook of getAfterHooks(operation, this.model)) {
150
- await hook(context);
151
- }
152
-
153
- return response;
154
- };
155
- }
156
- ```
157
-
158
- ## Usage Examples
159
-
160
- ### Validation (Halting)
161
-
162
- ```javascript
163
- beforeHook('create', 'animal', (context) => {
164
- const { age } = context.body.data.attributes;
165
- if (age < 0) {
166
- return 400; // Halt with Bad Request
167
- }
168
- });
169
- ```
170
-
171
- ### Custom Error Response
172
-
173
- ```javascript
174
- beforeHook('delete', 'animal', (context) => {
175
- const animal = store.get('animal', context.params.id);
176
- if (animal.protected) {
177
- return { errors: [{ detail: 'Cannot delete protected animals' }] };
178
- }
179
- });
180
- ```
181
-
182
- ### Change Detection with oldState
183
-
184
- ```javascript
185
- afterHook('update', 'animal', (context) => {
186
- if (!context.oldState) return;
187
-
188
- // Detect specific field changes
189
- if (context.oldState.owner !== context.record.owner) {
190
- console.log(`Owner changed from ${context.oldState.owner} to ${context.record.owner}`);
191
- }
192
- });
193
- ```
194
-
195
- ### Audit Logging
196
-
197
- ```javascript
198
- afterHook('update', 'animal', async (context) => {
199
- const changes = {};
200
- if (context.oldState) {
201
- for (const [key, newValue] of Object.entries(context.record.__data)) {
202
- if (context.oldState[key] !== newValue) {
203
- changes[key] = { from: context.oldState[key], to: newValue };
204
- }
205
- }
206
- }
207
-
208
- await auditLog.create({
209
- operation: 'update',
210
- model: context.model,
211
- recordId: context.record.id,
212
- changes // { age: { from: 2, to: 3 } }
213
- });
214
- });
215
- ```
216
-
217
- ### Delete Auditing
218
-
219
- ```javascript
220
- afterHook('delete', 'animal', async (context) => {
221
- await auditLog.create({
222
- operation: 'delete',
223
- model: context.model,
224
- recordId: context.recordId,
225
- deletedData: context.oldState // Full snapshot
226
- });
227
- });
228
- ```
229
-
230
- ## Key Differences from Event-Based System
231
-
232
- | Feature | Event-Based (Old) | Middleware-Based (Current) |
233
- |---------|-------------------|---------------------------|
234
- | Execution | Parallel (fire-and-forget) | Sequential |
235
- | Can halt operation | No | Yes (return any value) |
236
- | Error handling | Isolated (logged) | Propagated (halts operation) |
237
- | Middleware order | Not guaranteed | Registration order |
238
- | Context modification | Not reliable | Reliable (sequential) |
239
- | API | `subscribe('before:create:animal')` | `beforeHook('create', 'animal')` |
240
-
241
- ## Testing
242
-
243
- **Location**: `test/integration/orm-test.js`
244
- **Coverage**: Comprehensive hook tests including:
245
- - Before/after hooks for all operations
246
- - Halting with status codes
247
- - Halting with custom response objects
248
- - Sequential execution order
249
- - Unsubscribe functionality
250
- - clearHook functionality