@stonyx/orm 0.2.1-alpha.2 → 0.2.1-alpha.21

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 (46) hide show
  1. package/.claude/code-style-rules.md +44 -0
  2. package/.claude/hooks.md +250 -0
  3. package/.claude/index.md +292 -0
  4. package/.claude/usage-patterns.md +300 -0
  5. package/.claude/views.md +292 -0
  6. package/.github/workflows/ci.yml +5 -25
  7. package/.github/workflows/publish.yml +24 -116
  8. package/README.md +461 -15
  9. package/config/environment.js +29 -6
  10. package/improvements.md +139 -0
  11. package/package.json +24 -8
  12. package/project-structure.md +343 -0
  13. package/scripts/setup-test-db.sh +21 -0
  14. package/src/aggregates.js +93 -0
  15. package/src/belongs-to.js +4 -1
  16. package/src/commands.js +170 -0
  17. package/src/db.js +132 -6
  18. package/src/has-many.js +4 -1
  19. package/src/hooks.js +124 -0
  20. package/src/index.js +12 -2
  21. package/src/main.js +77 -4
  22. package/src/manage-record.js +30 -4
  23. package/src/migrate.js +72 -0
  24. package/src/model-property.js +2 -2
  25. package/src/model.js +11 -0
  26. package/src/mysql/connection.js +28 -0
  27. package/src/mysql/migration-generator.js +286 -0
  28. package/src/mysql/migration-runner.js +110 -0
  29. package/src/mysql/mysql-db.js +473 -0
  30. package/src/mysql/query-builder.js +64 -0
  31. package/src/mysql/schema-introspector.js +325 -0
  32. package/src/mysql/type-map.js +37 -0
  33. package/src/orm-request.js +313 -53
  34. package/src/plural-registry.js +12 -0
  35. package/src/record.js +35 -8
  36. package/src/serializer.js +9 -2
  37. package/src/setup-rest-server.js +5 -2
  38. package/src/store.js +130 -1
  39. package/src/utils.js +1 -1
  40. package/src/view-resolver.js +183 -0
  41. package/src/view.js +21 -0
  42. package/test-events-setup.js +41 -0
  43. package/test-hooks-manual.js +54 -0
  44. package/test-hooks-with-logging.js +52 -0
  45. package/.claude/project-structure.md +0 -578
  46. package/stonyx-bootstrap.cjs +0 -30
@@ -0,0 +1,300 @@
1
+ # Usage Patterns
2
+
3
+ ## 1. Model Definition
4
+
5
+ Models extend `Model` and use decorators for attributes and relationships:
6
+
7
+ ```javascript
8
+ // test/sample/models/animal.js
9
+ import { Model, attr, belongsTo, hasMany } from '@stonyx/orm';
10
+
11
+ export default class AnimalModel extends Model {
12
+ // Attributes with type transforms
13
+ type = attr('animal'); // Custom transform
14
+ age = attr('number'); // Built-in transform
15
+ size = attr('string');
16
+
17
+ // Relationships
18
+ owner = belongsTo('owner'); // Many-to-one
19
+ traits = hasMany('trait'); // One-to-many
20
+
21
+ // Computed properties
22
+ get tag() {
23
+ return `${this.owner.id}'s ${this.size} animal`;
24
+ }
25
+ }
26
+ ```
27
+
28
+ **Key Points:**
29
+ - Use `attr(type)` for simple attributes
30
+ - Use `belongsTo(modelName)` for many-to-one
31
+ - Use `hasMany(modelName)` for one-to-many
32
+ - Getters work as computed properties
33
+ - Relationships auto-establish bidirectionally
34
+ - Override auto-pluralization with `static pluralName` (see [Overriding Plural Names](#overriding-plural-names))
35
+
36
+ ### Overriding Plural Names
37
+
38
+ By default, model names are auto-pluralized (e.g., `animal` → `animals`) for REST routes, JSON:API URLs, and DB table names. When auto-pluralization produces the wrong result, override it with `static pluralName`:
39
+
40
+ ```javascript
41
+ import { Model, attr } from '@stonyx/orm';
42
+
43
+ export default class PersonModel extends Model {
44
+ static pluralName = 'people';
45
+
46
+ name = attr('string');
47
+ }
48
+ ```
49
+
50
+ The override is picked up automatically during ORM initialization — no additional registration is needed. All internal call sites (REST routes, JSON:API type references, MySQL table names, foreign key references) use the overridden value.
51
+
52
+ ## 2. Serializers (Data Transformation)
53
+
54
+ Serializers map raw data paths to model properties:
55
+
56
+ ```javascript
57
+ // test/sample/serializers/animal.js
58
+ import { Serializer } from '@stonyx/orm';
59
+
60
+ export default class AnimalSerializer extends Serializer {
61
+ map = {
62
+ // Nested path mapping
63
+ age: 'details.age',
64
+ size: 'details.c',
65
+ owner: 'details.location.owner',
66
+
67
+ // Custom transformation function
68
+ traits: ['details', ({ x:color }) => {
69
+ const traits = [{ id: 1, type: 'habitat', value: 'farm' }];
70
+ if (color) traits.push({ id: 2, type: 'color', value: color });
71
+ return traits;
72
+ }]
73
+ }
74
+ }
75
+ ```
76
+
77
+ **Key Points:**
78
+ - `map` object defines field mappings
79
+ - Supports nested paths (`'details.age'`)
80
+ - Custom functions for complex transformations
81
+ - Handlers receive raw data subset
82
+
83
+ ## 3. Custom Transforms
84
+
85
+ Transforms convert data types:
86
+
87
+ ```javascript
88
+ // test/sample/transforms/animal.js
89
+ const codeEnumMap = { 'dog': 1, 'cat': 2, 'bird': 3 };
90
+
91
+ export default function(value) {
92
+ return codeEnumMap[value] || 0;
93
+ }
94
+ ```
95
+
96
+ **Built-in Transforms:**
97
+ - Type: `boolean`, `number`, `float`, `string`, `date`, `timestamp`
98
+ - Math: `round`, `ceil`, `floor`
99
+ - String: `trim`, `uppercase`
100
+ - Utility: `passthrough`
101
+
102
+ ## 4. CRUD Operations
103
+
104
+ ```javascript
105
+ import { createRecord, updateRecord, store } from '@stonyx/orm';
106
+
107
+ // Create
108
+ createRecord('owner', { id: 'bob', age: 30 });
109
+
110
+ // Read
111
+ const owner = store.get('owner', 'bob');
112
+ const allOwners = store.get('owner');
113
+
114
+ // Update
115
+ updateRecord(owner, { age: 31 });
116
+ // Or direct: owner.age = 31;
117
+
118
+ // Delete
119
+ store.remove('owner', 'bob');
120
+ ```
121
+
122
+ ## 5. Database Schema
123
+
124
+ The DB schema is a Model defining top-level collections:
125
+
126
+ ```javascript
127
+ // test/sample/db-schema.js
128
+ import { Model, hasMany } from '@stonyx/orm';
129
+
130
+ export default class DBModel extends Model {
131
+ owners = hasMany('owner');
132
+ animals = hasMany('animal');
133
+ traits = hasMany('trait');
134
+ }
135
+ ```
136
+
137
+ ## 6. Persistence
138
+
139
+ ```javascript
140
+ import Orm from '@stonyx/orm';
141
+
142
+ // Save to file
143
+ await Orm.db.save();
144
+
145
+ // Data auto-serializes to JSON file
146
+ // Reload using createRecord with serialize:false, transform:false
147
+ ```
148
+
149
+ ## 7. Access Control
150
+
151
+ ```javascript
152
+ // test/sample/access/global-access.js
153
+ export default class GlobalAccess {
154
+ models = ['owner', 'animal']; // or '*' for all
155
+
156
+ access(request) {
157
+ // Deny specific access
158
+ if (request.url.endsWith('/owner/angela')) return false;
159
+
160
+ // Filter collections
161
+ if (request.url.endsWith('/owner')) {
162
+ return record => record.id !== 'angela';
163
+ }
164
+
165
+ // Grant CRUD permissions
166
+ return ['read', 'create', 'update', 'delete'];
167
+ }
168
+ }
169
+ ```
170
+
171
+ ## 8. REST API (Auto-generated)
172
+
173
+ ```javascript
174
+ // Endpoints auto-generated for models:
175
+ // GET /owners - List all
176
+ // GET /owners/:id - Get one
177
+ // POST /animals - Create
178
+ // PATCH /animals/:id - Update (attributes and/or relationships)
179
+ // DELETE /animals/:id - Delete
180
+ ```
181
+
182
+ **PATCH supports both attributes and relationships:**
183
+ ```javascript
184
+ // Update attributes only
185
+ PATCH /animals/1
186
+ { data: { type: 'animal', attributes: { age: 5 } } }
187
+
188
+ // Update relationship only
189
+ PATCH /animals/1
190
+ { data: { type: 'animal', relationships: { owner: { data: { type: 'owner', id: 'gina' } } } } }
191
+
192
+ // Update both
193
+ PATCH /animals/1
194
+ { data: { type: 'animal', attributes: { age: 5 }, relationships: { owner: { data: { type: 'owner', id: 'gina' } } } } }
195
+ ```
196
+
197
+ ## 9. Include Parameter (Sideloading)
198
+
199
+ GET endpoints support sideloading related records with **nested relationship traversal**:
200
+
201
+ ```javascript
202
+ // Single-level includes
203
+ GET /animals/1?include=owner,traits
204
+
205
+ // Nested includes (NEW!)
206
+ GET /animals/1?include=owner.pets,owner.company
207
+
208
+ // Deep nesting (3+ levels)
209
+ GET /scenes/e001-s001?include=slides.dialogue.character
210
+
211
+ // Response structure (unchanged)
212
+ {
213
+ data: { type: 'animal', id: 1, attributes: {...}, relationships: {...} },
214
+ included: [
215
+ { type: 'owner', id: 'angela', ... },
216
+ { type: 'animal', id: 7, ... }, // owner's other pets
217
+ { type: 'animal', id: 11, ... }, // owner's other pets
218
+ { type: 'company', id: 'acme', ... } // owner's company (if requested)
219
+ ]
220
+ }
221
+ ```
222
+
223
+ **How Nested Includes Work:**
224
+ 1. Query param parsed into path segments: `owner.pets` -> `[['owner'], ['owner', 'pets'], ['traits']]`
225
+ 2. `traverseIncludePath()` recursively traverses relationships depth-first
226
+ 3. Deduplication still by type+id (no duplicates in included array)
227
+ 4. Gracefully handles null/missing relationships at any depth
228
+ 5. Each included record gets full `toJSON()` representation
229
+
230
+ **Key Functions:**
231
+ - `parseInclude()` - Splits comma-separated includes and parses nested paths
232
+ - `traverseIncludePath()` - Recursively traverses relationship paths
233
+ - `collectIncludedRecords()` - Orchestrates traversal and deduplication
234
+ - All implemented in [src/orm-request.js](src/orm-request.js)
235
+
236
+ ## 10. Views (Read-Only Computed Data)
237
+
238
+ Views are read-only projections that compute derived data from existing models. They work in both JSON mode (in-memory) and MySQL mode (auto-generated SQL VIEWs). See the full guide at [views.md](views.md).
239
+
240
+ ### Defining a View
241
+
242
+ ```javascript
243
+ // views/owner-stats.js
244
+ import { View, attr, belongsTo, count, avg } from '@stonyx/orm';
245
+
246
+ export default class OwnerStatsView extends View {
247
+ static source = 'owner'; // Required: model whose records produce view records
248
+
249
+ animalCount = count('pets'); // COUNT of hasMany relationship
250
+ averageAge = avg('pets', 'age'); // AVG of a field on related records
251
+ owner = belongsTo('owner'); // Link back to source record
252
+ }
253
+ ```
254
+
255
+ ### Aggregate Helpers
256
+
257
+ | Helper | Example | JS Behavior | MySQL |
258
+ |--------|---------|-------------|-------|
259
+ | `count(rel)` | `count('pets')` | `records.length` | `COUNT(table.id)` |
260
+ | `avg(rel, field)` | `avg('pets', 'age')` | Average of values | `AVG(table.field)` |
261
+ | `sum(rel, field)` | `sum('pets', 'age')` | Sum of values | `SUM(table.field)` |
262
+ | `min(rel, field)` | `min('pets', 'age')` | Minimum value | `MIN(table.field)` |
263
+ | `max(rel, field)` | `max('pets', 'age')` | Maximum value | `MAX(table.field)` |
264
+
265
+ ### Resolve Map (Escape Hatch)
266
+
267
+ For fields that can't be expressed as aggregates:
268
+
269
+ ```javascript
270
+ export default class OwnerStatsView extends View {
271
+ static source = 'owner';
272
+ static resolve = {
273
+ gender: 'gender', // String path from source data
274
+ score: (owner) => owner.__data.age * 10, // Function
275
+ };
276
+
277
+ gender = attr('string'); // Must also define as attr()
278
+ score = attr('number');
279
+ animalCount = count('pets');
280
+ }
281
+ ```
282
+
283
+ ### Querying Views
284
+
285
+ ```javascript
286
+ const stats = await store.findAll('owner-stats');
287
+ const stat = await store.find('owner-stats', ownerId);
288
+ ```
289
+
290
+ ### Read-Only Enforcement
291
+
292
+ ```javascript
293
+ createRecord('owner-stats', data); // Throws: Cannot create records for read-only view
294
+ updateRecord(viewRecord, data); // Throws: Cannot update records for read-only view
295
+ store.remove('owner-stats', id); // Throws: Cannot remove records from read-only view
296
+ ```
297
+
298
+ ### REST API
299
+
300
+ Only GET endpoints are mounted for views — no POST, PATCH, or DELETE.
@@ -0,0 +1,292 @@
1
+ # Views
2
+
3
+ Views are read-only, model-like structures that compute derived data from existing models. They work in both JSON file mode (in-memory computation) and MySQL mode (auto-generated SQL VIEWs).
4
+
5
+ ## What Views Are
6
+
7
+ A View defines a read-only projection over source model data. Use views when you need:
8
+ - Aggregated data (counts, averages, sums) derived from model relationships
9
+ - Computed read-only summaries that shouldn't be persisted as separate records
10
+ - MySQL VIEWs that are auto-generated from your JavaScript definition
11
+
12
+ ## Defining a View
13
+
14
+ Views extend the `View` base class and are placed in the `views/` directory (configurable via `paths.view`).
15
+
16
+ ```javascript
17
+ // views/owner-stats.js
18
+ import { View, attr, belongsTo, count, avg } from '@stonyx/orm';
19
+
20
+ export default class OwnerStatsView extends View {
21
+ static source = 'owner'; // The model whose records are iterated
22
+
23
+ animalCount = count('pets'); // COUNT of hasMany relationship
24
+ averageAge = avg('pets', 'age'); // AVG of a field on related records
25
+ owner = belongsTo('owner'); // Link back to the source record
26
+ }
27
+ ```
28
+
29
+ ### File Naming
30
+
31
+ - File: `owner-stats.js` → Class: `OwnerStatsView` → Store name: `'owner-stats'`
32
+ - Directory: configured via `paths.view` (default: `'./views'`)
33
+ - Environment variable: `ORM_VIEW_PATH`
34
+
35
+ ### Key Static Properties
36
+
37
+ | Property | Default | Description |
38
+ |----------|---------|-------------|
39
+ | `source` | `undefined` | **(Required)** The model name whose records produce view records |
40
+ | `groupBy` | `undefined` | Field name to group source records by (one view record per unique value) |
41
+ | `resolve` | `undefined` | Optional escape-hatch map for custom derivations |
42
+ | `memory` | `false` | `false` = computed on demand; `true` = cached on startup |
43
+ | `readOnly` | `true` | **Enforced** — cannot be overridden to `false` |
44
+ | `pluralName` | `undefined` | Custom plural name (same as Model) |
45
+
46
+ ## Aggregate Helpers
47
+
48
+ Aggregate helpers define fields that compute values from related records. Each helper knows both its JavaScript computation logic and its MySQL SQL translation.
49
+
50
+ | Helper | JS Behavior | MySQL Translation |
51
+ |--------|-------------|-------------------|
52
+ | `count(relationship)` | `relatedRecords.length` | `COUNT(table.id)` |
53
+ | `avg(relationship, field)` | Average of field values | `AVG(table.field)` |
54
+ | `sum(relationship, field)` | Sum of field values | `SUM(table.field)` |
55
+ | `min(relationship, field)` | Minimum field value | `MIN(table.field)` |
56
+ | `max(relationship, field)` | Maximum field value | `MAX(table.field)` |
57
+
58
+ ### Empty/Null Handling
59
+
60
+ - `count` with empty/null relationship → `0`
61
+ - `avg` with empty/null relationship → `0`
62
+ - `sum` with empty/null relationship → `0`
63
+ - `min` with empty/null relationship → `null`
64
+ - `max` with empty/null relationship → `null`
65
+ - Non-numeric values are filtered/treated as 0
66
+
67
+ ## Resolve Map (Escape Hatch)
68
+
69
+ For computed fields that can't be expressed as aggregates, use `static resolve`:
70
+
71
+ ```javascript
72
+ export default class OwnerStatsView extends View {
73
+ static source = 'owner';
74
+
75
+ static resolve = {
76
+ gender: 'gender', // String path: maps from source record data
77
+ score: (owner) => { // Function: custom computation
78
+ return owner.__data.age * 10;
79
+ },
80
+ nestedVal: 'details.nested', // Nested string paths supported
81
+ };
82
+
83
+ // Must also define as attr() for the serializer to process
84
+ gender = attr('string');
85
+ score = attr('number');
86
+ nestedVal = attr('passthrough');
87
+
88
+ animalCount = count('pets');
89
+ }
90
+ ```
91
+
92
+ **Important:** Each resolve map entry needs a corresponding `attr()` field definition on the view.
93
+
94
+ ## GroupBy Views
95
+
96
+ GroupBy views produce one view record per unique value of a field on the source model, with aggregates computed within each group.
97
+
98
+ ### Defining a GroupBy View
99
+
100
+ ```javascript
101
+ import { View, attr, count, avg, sum } from '@stonyx/orm';
102
+
103
+ export default class AnimalCountBySizeView extends View {
104
+ static source = 'animal';
105
+ static groupBy = 'size'; // Group animals by their size field
106
+
107
+ id = attr('string'); // The group key becomes the id
108
+ animalCount = count(); // Count records in each group
109
+ averageAge = avg('age'); // Average of 'age' field within each group
110
+ }
111
+ ```
112
+
113
+ ### Aggregate Helpers in GroupBy Views
114
+
115
+ In groupBy views, aggregate helpers operate on the group's records rather than on relationships:
116
+
117
+ | Helper | GroupBy Behavior | MySQL Translation |
118
+ |--------|-----------------|-------------------|
119
+ | `count()` | Number of records in the group | `COUNT(*)` |
120
+ | `sum('field')` | Sum of field across group records | `SUM(source.field)` |
121
+ | `avg('field')` | Average of field across group records | `AVG(source.field)` |
122
+ | `min('field')` | Minimum field value in the group | `MIN(source.field)` |
123
+ | `max('field')` | Maximum field value in the group | `MAX(source.field)` |
124
+
125
+ Relationship aggregates (e.g., `count('traits')`) also work — they flatten related records across all group members and aggregate the combined set.
126
+
127
+ ### Resolve Map in GroupBy Views
128
+
129
+ The resolve map behaves differently in groupBy views:
130
+ - **Function resolvers** receive the **array of group records** (not a single record)
131
+ - **String path resolvers** take the value from the **first record** in the group
132
+
133
+ ```javascript
134
+ export default class LeagueStatsView extends View {
135
+ static source = 'game-stats';
136
+ static groupBy = 'competition';
137
+
138
+ static resolve = {
139
+ totalGoals: (groupRecords) => {
140
+ return groupRecords.reduce((sum, r) => {
141
+ const fs = r.__data.finalScore;
142
+ return fs ? sum + fs[0] + fs[1] : sum;
143
+ }, 0);
144
+ },
145
+ };
146
+
147
+ matchCount = count();
148
+ totalGoals = attr('number');
149
+ }
150
+ ```
151
+
152
+ ### MySQL DDL for GroupBy Views
153
+
154
+ ```sql
155
+ CREATE OR REPLACE VIEW `animal-count-by-sizes` AS
156
+ SELECT
157
+ `animals`.`size` AS `id`,
158
+ COUNT(*) AS `animalCount`,
159
+ AVG(`animals`.`age`) AS `averageAge`
160
+ FROM `animals`
161
+ GROUP BY `animals`.`size`
162
+ ```
163
+
164
+ ## Querying Views
165
+
166
+ Views use the same store API as models:
167
+
168
+ ```javascript
169
+ // All view records (computed fresh each call in JSON mode)
170
+ const stats = await store.findAll('owner-stats');
171
+
172
+ // Single view record by source ID
173
+ const stat = await store.find('owner-stats', ownerId);
174
+
175
+ // With conditions
176
+ const filtered = await store.findAll('owner-stats', { animalCount: 5 });
177
+ ```
178
+
179
+ ## Read-Only Enforcement
180
+
181
+ Views are strictly read-only at all layers:
182
+
183
+ ```javascript
184
+ // All of these throw errors:
185
+ createRecord('owner-stats', data); // Error: Cannot create records for read-only view
186
+ updateRecord(viewRecord, data); // Error: Cannot update records for read-only view
187
+ store.remove('owner-stats', id); // Error: Cannot remove records from read-only view
188
+
189
+ // Internal use only — bypasses guard:
190
+ createRecord('owner-stats', data, { isDbRecord: true }); // Used by resolver/loader
191
+ ```
192
+
193
+ ## REST API Behavior
194
+
195
+ When a view is included in an access configuration, only GET endpoints are mounted:
196
+
197
+ - `GET /view-plural-name` — Returns list of view records
198
+ - `GET /view-plural-name/:id` — Returns single view record
199
+ - `GET /view-plural-name/:id/{relationship}` — Related resources
200
+ - No POST, PATCH, DELETE endpoints
201
+
202
+ ## JSON Mode (In-Memory Resolver)
203
+
204
+ In JSON/non-MySQL mode, the ViewResolver:
205
+
206
+ 1. Iterates all records of the `source` model from the store
207
+ 2. For each source record:
208
+ - Computes aggregate properties from relationships
209
+ - Applies resolve map entries (string paths or functions)
210
+ - Maps regular attr fields from source data
211
+ 3. Creates view records via `createRecord` with `isDbRecord: true`
212
+ 4. Returns computed array
213
+
214
+ ## MySQL Mode
215
+
216
+ In MySQL mode:
217
+
218
+ 1. **Schema introspection** generates VIEW metadata via `introspectViews()`
219
+ 2. **DDL generation** creates `CREATE OR REPLACE VIEW` SQL from aggregates and relationships
220
+ 3. **Queries** use `SELECT * FROM \`view_name\`` just like tables
221
+ 4. **Migrations** include `CREATE OR REPLACE VIEW` after table statements
222
+ 5. **persist()** is a no-op for views
223
+ 6. **loadMemoryRecords()** loads views with `memory: true` from the MySQL VIEW
224
+
225
+ ### Generated SQL Example
226
+
227
+ For `OwnerStatsView` with `count('pets')` and `avg('pets', 'age')`:
228
+
229
+ ```sql
230
+ CREATE OR REPLACE VIEW `owner-stats` AS
231
+ SELECT
232
+ `owners`.`id` AS `id`,
233
+ COUNT(`animals`.`id`) AS `animalCount`,
234
+ AVG(`animals`.`age`) AS `avgAge`
235
+ FROM `owners`
236
+ LEFT JOIN `animals` ON `animals`.`owner_id` = `owners`.`id`
237
+ GROUP BY `owners`.`id`
238
+ ```
239
+
240
+ ## Migration Support
241
+
242
+ Views are handled in migrations alongside tables:
243
+
244
+ - **Added views** → `CREATE OR REPLACE VIEW ...` in UP, `DROP VIEW IF EXISTS ...` in DOWN
245
+ - **Removed views** → Commented `DROP VIEW` warning in UP (matching model removal pattern)
246
+ - **Changed views** → `CREATE OR REPLACE VIEW ...` in UP (replaces automatically)
247
+ - Views appear AFTER table statements in migrations (dependency order)
248
+ - Snapshots include view entries with `isView: true` and `viewQuery`
249
+
250
+ ## Memory Flag
251
+
252
+ - `static memory = false` (default) — View records are computed fresh on each query
253
+ - `static memory = true` — View records are loaded from MySQL VIEW on startup and cached
254
+
255
+ ## Testing Views
256
+
257
+ ```javascript
258
+ import QUnit from 'qunit';
259
+ import { store } from '@stonyx/orm';
260
+
261
+ QUnit.test('view returns computed data', async function(assert) {
262
+ // Create source data
263
+ createRecord('owner', { id: 1, name: 'Alice' }, { serialize: false });
264
+ createRecord('animal', { id: 1, age: 3, owner: 1 }, { serialize: false });
265
+
266
+ // Query the view
267
+ const results = await store.findAll('owner-stats');
268
+ const stat = results.find(r => r.id === 1);
269
+
270
+ assert.strictEqual(stat.__data.animalCount, 1);
271
+ });
272
+ ```
273
+
274
+ ## Architecture
275
+
276
+ ### Source Files
277
+
278
+ | File | Purpose |
279
+ |------|---------|
280
+ | `src/view.js` | View base class |
281
+ | `src/aggregates.js` | AggregateProperty class + helper functions |
282
+ | `src/view-resolver.js` | In-memory view resolver |
283
+ | `src/mysql/schema-introspector.js` | `introspectViews()`, `buildViewDDL()` |
284
+ | `src/mysql/migration-generator.js` | `diffViewSnapshots()`, view migration generation |
285
+
286
+ ### Key Design Decisions
287
+
288
+ 1. **View does NOT extend Model** — conceptually separate; shared behavior is minimal
289
+ 2. **Driver-agnostic API** — No SQL in view definitions; MySQL driver generates SQL automatically
290
+ 3. **Aggregate helpers follow the transform pattern** — Each knows both JS computation and MySQL mapping
291
+ 4. **Resolve map mirrors Serializer.map** — String paths or function resolvers
292
+ 5. **View schemas are separate** — `introspectViews()` is separate from `introspectModels()`
@@ -2,35 +2,15 @@ name: CI
2
2
 
3
3
  on:
4
4
  pull_request:
5
- branches:
6
- - dev
7
- - main
5
+ branches: [dev, main]
8
6
 
9
7
  concurrency:
10
8
  group: ci-${{ github.head_ref || github.ref }}
11
9
  cancel-in-progress: true
12
10
 
11
+ permissions:
12
+ contents: read
13
+
13
14
  jobs:
14
15
  test:
15
- runs-on: ubuntu-latest
16
-
17
- steps:
18
- - name: Checkout code
19
- uses: actions/checkout@v3
20
-
21
- - name: Setup pnpm
22
- uses: pnpm/action-setup@v4
23
- with:
24
- version: 9
25
-
26
- - name: Set up Node.js
27
- uses: actions/setup-node@v3
28
- with:
29
- node-version: 24.13.0
30
- cache: 'pnpm'
31
-
32
- - name: Install dependencies
33
- run: pnpm install --frozen-lockfile
34
-
35
- - name: Run tests
36
- run: pnpm test
16
+ uses: abofs/stonyx-workflows/.github/workflows/ci.yml@main