@stonyx/orm 0.2.1-beta.8 → 0.2.1-beta.81
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/README.md +64 -6
- package/config/environment.js +3 -1
- package/package.json +20 -6
- package/src/aggregates.js +93 -0
- package/src/belongs-to.js +11 -4
- package/src/cli.js +177 -0
- package/src/db.js +14 -4
- package/src/has-many.js +8 -1
- package/src/index.js +11 -2
- package/src/main.js +52 -8
- package/src/manage-record.js +16 -3
- package/src/model-property.js +2 -2
- package/src/model.js +11 -0
- package/src/mysql/migration-generator.js +103 -5
- package/src/mysql/mysql-db.js +163 -10
- package/src/mysql/schema-introspector.js +182 -15
- package/src/orm-request.js +35 -29
- package/src/plural-registry.js +12 -0
- package/src/record.js +7 -2
- package/src/serializer.js +9 -2
- package/src/setup-rest-server.js +3 -3
- package/src/standalone-db.js +176 -0
- package/src/store.js +130 -1
- package/src/view-resolver.js +183 -0
- package/src/view.js +21 -0
- package/.claude/code-style-rules.md +0 -44
- package/.claude/hooks.md +0 -250
- package/.claude/index.md +0 -279
- package/.claude/usage-patterns.md +0 -217
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- package/improvements.md +0 -139
- package/project-structure.md +0 -343
- package/test-events-setup.js +0 -41
- package/test-hooks-manual.js +0 -54
- package/test-hooks-with-logging.js +0 -52
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
export default class StandaloneDB {
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {string} options.dbPath - Path to db.json (file mode) or parent of db dir (directory mode)
|
|
16
|
+
* @param {string} [options.mode='directory'] - 'file' or 'directory'
|
|
17
|
+
* @param {string} [options.directory='db'] - Directory name when mode is 'directory'
|
|
18
|
+
*/
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.mode = options.mode || 'directory';
|
|
21
|
+
this.dbPath = options.dbPath || 'db.json';
|
|
22
|
+
this.directory = options.directory || 'db';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the directory path for directory mode.
|
|
27
|
+
*/
|
|
28
|
+
getDirPath() {
|
|
29
|
+
const dbDir = path.dirname(path.resolve(this.dbPath));
|
|
30
|
+
return path.join(dbDir, this.directory);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* List available collections by inspecting either the db.json keys
|
|
35
|
+
* or the files in the db directory.
|
|
36
|
+
*/
|
|
37
|
+
async getCollections() {
|
|
38
|
+
if (this.mode === 'directory') {
|
|
39
|
+
const dirPath = this.getDirPath();
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const files = await fs.readdir(dirPath);
|
|
43
|
+
return files
|
|
44
|
+
.filter(f => f.endsWith('.json'))
|
|
45
|
+
.map(f => f.replace('.json', ''));
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// File mode — read db.json and return its top-level keys
|
|
52
|
+
try {
|
|
53
|
+
const data = await this._readJSON(this.dbPath);
|
|
54
|
+
return Object.keys(data).filter(key => Array.isArray(data[key]));
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read all records for a collection.
|
|
62
|
+
*/
|
|
63
|
+
async readCollection(collection) {
|
|
64
|
+
if (this.mode === 'directory') {
|
|
65
|
+
const filePath = path.join(this.getDirPath(), `${collection}.json`);
|
|
66
|
+
return this._readJSON(filePath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data = await this._readJSON(this.dbPath);
|
|
70
|
+
return data[collection] || [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Write all records for a collection.
|
|
75
|
+
*/
|
|
76
|
+
async writeCollection(collection, records) {
|
|
77
|
+
if (this.mode === 'directory') {
|
|
78
|
+
const dirPath = this.getDirPath();
|
|
79
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
80
|
+
|
|
81
|
+
const filePath = path.join(dirPath, `${collection}.json`);
|
|
82
|
+
await this._writeJSON(filePath, records);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// File mode — read full db, update collection, write back
|
|
87
|
+
let data;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
data = await this._readJSON(this.dbPath);
|
|
91
|
+
} catch {
|
|
92
|
+
data = {};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
data[collection] = records;
|
|
96
|
+
await this._writeJSON(this.dbPath, data);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get a single record by id.
|
|
101
|
+
*/
|
|
102
|
+
async get(collection, id) {
|
|
103
|
+
const records = await this.readCollection(collection);
|
|
104
|
+
const numericId = Number(id);
|
|
105
|
+
|
|
106
|
+
return records.find(r =>
|
|
107
|
+
r.id === id || r.id === numericId
|
|
108
|
+
) || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* List all records in a collection.
|
|
113
|
+
*/
|
|
114
|
+
async list(collection) {
|
|
115
|
+
return this.readCollection(collection);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create a new record. Auto-assigns an integer id if none provided.
|
|
120
|
+
*/
|
|
121
|
+
async create(collection, data) {
|
|
122
|
+
const records = await this.readCollection(collection);
|
|
123
|
+
|
|
124
|
+
if (!data.id) {
|
|
125
|
+
const maxId = records.reduce((max, r) => {
|
|
126
|
+
const rid = typeof r.id === 'number' ? r.id : 0;
|
|
127
|
+
return rid > max ? rid : max;
|
|
128
|
+
}, 0);
|
|
129
|
+
|
|
130
|
+
data.id = maxId + 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check for duplicate id
|
|
134
|
+
const existing = records.find(r => r.id === data.id);
|
|
135
|
+
if (existing) {
|
|
136
|
+
throw new Error(`Record with id ${data.id} already exists in '${collection}'`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
records.push(data);
|
|
140
|
+
await this.writeCollection(collection, records);
|
|
141
|
+
|
|
142
|
+
return data;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Delete a record by id.
|
|
147
|
+
*/
|
|
148
|
+
async delete(collection, id) {
|
|
149
|
+
const records = await this.readCollection(collection);
|
|
150
|
+
const numericId = Number(id);
|
|
151
|
+
|
|
152
|
+
const index = records.findIndex(r =>
|
|
153
|
+
r.id === id || r.id === numericId
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (index === -1) {
|
|
157
|
+
throw new Error(`Record with id '${id}' not found in '${collection}'`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const [removed] = records.splice(index, 1);
|
|
161
|
+
await this.writeCollection(collection, records);
|
|
162
|
+
|
|
163
|
+
return removed;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// -- Private helpers --
|
|
167
|
+
|
|
168
|
+
async _readJSON(filePath) {
|
|
169
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
170
|
+
return JSON.parse(content);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async _writeJSON(filePath, data) {
|
|
174
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
175
|
+
}
|
|
176
|
+
}
|
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 the SQL database 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-SQL mode, use view resolver
|
|
33
|
+
if (Orm.instance?.isView?.(modelName) && !this._sqlDb) {
|
|
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 the SQL database
|
|
44
|
+
if (this._sqlDb) {
|
|
45
|
+
return this._sqlDb.findRecord(modelName, id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fallback to store (JSON mode or no SQL adapter)
|
|
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-SQL mode, use view resolver
|
|
61
|
+
if (Orm.instance?.isView?.(modelName) && !this._sqlDb) {
|
|
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 the SQL database
|
|
79
|
+
if (this._sqlDb) {
|
|
80
|
+
return this._sqlDb.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._sqlDb) {
|
|
105
|
+
return this._sqlDb.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 SQL adapter instance for on-demand queries.
|
|
129
|
+
* @type {object|null}
|
|
130
|
+
*/
|
|
131
|
+
_sqlDb = 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)
|