@stonyx/orm 0.1.0 → 0.2.1-alpha.0

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/db.js CHANGED
@@ -1,18 +1,26 @@
1
- /**
2
- * TODO:
3
- *
4
- * ORM DB usage assumes that 100% of the data is ORM driven
5
- * With that assumption, we can safely do the following
6
- * - On save: Remove computed properties (getters) from data set
7
- * - On load: Compute computed properties from data set
8
- * - Error handling: Warn of non-ORM properties found in data (but load them)
9
- * - Optional configuration flag to disable these warnings
1
+ /*
2
+ * Copyright 2025 Stone Costa
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
10
15
  */
16
+
11
17
  import Cron from '@stonyx/cron';
12
18
  import config from 'stonyx/config';
13
19
  import log from 'stonyx/log';
20
+ import Orm, { createRecord, store } from '@stonyx/orm';
14
21
  import { createFile, updateFile, readFile } from '@stonyx/utils/file';
15
- import { deepCopy } from '@stonyx/utils/object';
22
+
23
+ export const dbKey = '__db';
16
24
 
17
25
  export default class DB {
18
26
  constructor() {
@@ -21,10 +29,22 @@ export default class DB {
21
29
  DB.instance = this;
22
30
  }
23
31
 
24
- async init() {
25
- await this.retrieve();
32
+ async getSchema() {
33
+ const { rootPath } = config;
34
+ const { file, schema } = config.orm.db;
35
+
36
+ if (!file) throw new Error('Configuration Error: ORM DB file path must be defined.');
26
37
 
38
+ return (await import(`${rootPath}/${schema}`)).default;
39
+ }
40
+
41
+ async init() {
27
42
  const { autosave, saveInterval } = config.orm.db;
43
+
44
+ store.set(dbKey, new Map());
45
+ Orm.instance.models[`${dbKey}Model`] = await this.getSchema();
46
+
47
+ this.record = await this.getRecord();
28
48
 
29
49
  if (autosave !== 'true') return;
30
50
 
@@ -33,74 +53,28 @@ export default class DB {
33
53
 
34
54
  async create() {
35
55
  const { rootPath } = config;
36
- const { file, schema } = config.orm.db;
37
-
38
- if (!file) throw new Error('Configuration Error: ORM DB file path must be defined.');
39
-
40
- let dbSchema;
41
-
42
- try {
43
- dbSchema = (await import(`${rootPath}/${schema}`)).default;
44
- } catch (error) {
45
- dbSchema = {};
46
- log.db('Unable to load DB schema from file, using empty schema instead');
47
- }
56
+ const { file } = config.orm.db;
48
57
 
49
- const data = deepCopy(dbSchema);
50
-
51
- createFile(`${rootPath}/${file}`, data, { json: true });
58
+ createFile(`${rootPath}/${file}`, {}, { json: true });
52
59
 
53
- return data;
60
+ return {};
54
61
  }
55
62
 
56
63
  async save() {
57
64
  const { file } = config.orm.db;
65
+ const jsonData = this.record.format();
66
+ delete jsonData.id; // Don't save id
58
67
 
59
- await updateFile(`${config.rootPath}/${file}`, this.data, { json: true });
68
+ await updateFile(`${config.rootPath}/${file}`, jsonData, { json: true });
60
69
 
61
70
  log.db(`DB has been successfully saved to ${file}`);
62
71
  }
63
72
 
64
- async retrieve() {
73
+ async getRecord() {
65
74
  const { file } = config.orm.db;
66
75
 
67
- this.data = await readFile(file, { json: true, missingFileCallback: this.create.bind(this) });
68
- }
69
-
70
- /** TODO: We need ORM specific reload logic that replaces models attributes when loading from DB */
71
- // _tempORMSerializeMeta(data) {
72
- // const { meta } = data;
73
-
74
- // // HACK: Create map to ensure we have no duplicate references
75
- // // This will no longer be necessary once once gatherer method prevents duplicates
76
- // const referenceIds = {};
77
- // const { shipmentReportReferences } = meta;
76
+ const data = await readFile(file, { json: true, missingFileCallback: this.create.bind(this) });
78
77
 
79
- // // Fix reference dates & remove duplicates
80
- // for (let i = shipmentReportReferences.length - 1; i >= 0; i--) {
81
- // const record = shipmentReportReferences[i];
82
-
83
- // if (!referenceIds[record.id]) {
84
- // referenceIds[record.id] = record;
85
- // } else {
86
- // shipmentReportReferences.splice(i, 1);
87
- // }
88
-
89
- // if (!record.date) continue;
90
-
91
- // record.date = new Date(record.date);
92
- // }
93
-
94
- // // Re-compute
95
- // const metaModel = new MODELS.MetaModel();
96
-
97
- // // Serialize computed properties
98
- // for (const [key, method] of getComputedProperties(metaModel)) {
99
- // const value = method.bind(meta)();
100
-
101
- // meta[key] = value;
102
- // }
103
-
104
- // return data;
105
- // }
78
+ return createRecord(dbKey, data, { isDbRecord: true, serialize: false, transform: false });
79
+ }
106
80
  }
@@ -0,0 +1,7 @@
1
+ import Orm from '@stonyx/orm';
2
+
3
+ const { db } = Orm;
4
+
5
+ export default db;
6
+ export const data = db.record;
7
+ export const saveDB = db.save.bind(db);
@@ -0,0 +1,61 @@
1
+ import { createRecord, relationships, store } from '@stonyx/orm';
2
+ import { getRelationships } from './relationships.js';
3
+ import { getOrSet, makeArray } from '@stonyx/utils/object';
4
+ import { dbKey } from './db.js';
5
+
6
+ function queuePendingRelationship(pendingRelationshipQueue, pendingRelationships, modelName, id) {
7
+ pendingRelationshipQueue.push({
8
+ pendingRelationship: getOrSet(pendingRelationships, modelName, new Map()),
9
+ id
10
+ });
11
+
12
+ return null;
13
+ }
14
+
15
+ export default function hasMany(modelName) {
16
+ const globalRelationships = relationships.get('global');
17
+ const pendingRelationships = relationships.get('pending');
18
+
19
+ return (sourceRecord, rawData, options) => {
20
+ const { __name: sourceModelName } = sourceRecord.__model;
21
+ const relationshipId = sourceRecord.id;
22
+ const relationship = getRelationships('hasMany', sourceModelName, modelName, relationshipId);
23
+ const modelStore = store.get(modelName);
24
+ const pendingRelationshipQueue = [];
25
+
26
+ const output = !rawData ? [] : makeArray(rawData).map(elementData => {
27
+ let record;
28
+
29
+ if (typeof elementData !== 'object') {
30
+ record = modelStore.get(elementData);
31
+
32
+ if (!record) {
33
+ return queuePendingRelationship(pendingRelationshipQueue, pendingRelationships, modelName, elementData);
34
+ }
35
+ } else {
36
+ if (elementData !== Object(elementData)) {
37
+ return queuePendingRelationship(pendingRelationshipQueue, pendingRelationships, modelName, elementData);
38
+ }
39
+
40
+ record = createRecord(modelName, elementData, options);
41
+ }
42
+
43
+ // Populate belongTo side if the relationship is defined
44
+ const otherSide = relationships.get('belongsTo').get(modelName)?.get(sourceModelName)?.get(record.id);
45
+
46
+ if (otherSide) Object.assign(otherSide, sourceRecord);
47
+
48
+ return record;
49
+ }).filter(value => value);
50
+
51
+ relationship.set(relationshipId, output);
52
+
53
+ // Assign global relationship
54
+ if (options.global || sourceModelName === dbKey) getOrSet(globalRelationships, modelName, []).push(output);
55
+
56
+ // Assign pending relationships
57
+ for (const { pendingRelationship, id } of pendingRelationshipQueue) getOrSet(pendingRelationship, id, []).push(output);
58
+
59
+ return output;
60
+ }
61
+ }
package/src/index.js ADDED
@@ -0,0 +1,28 @@
1
+ /*
2
+ * Copyright 2025 Stone Costa
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the 'License');
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import Model from './model.js';
18
+ import Serializer from './serializer.js';
19
+
20
+ import attr from './attr.js';
21
+ import belongsTo from './belongs-to.js';
22
+ import hasMany from './has-many.js';
23
+ import { createRecord, updateRecord } from './manage-record.js';
24
+
25
+ export { default } from './main.js';
26
+ export { store, relationships } from './main.js';
27
+ export { Model, Serializer }; // base classes
28
+ export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
package/src/main.js CHANGED
@@ -14,50 +14,85 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import DB from "@stonyx/orm/db";
18
- import config from "stonyx/config";
19
- import BaseModel from "./model.js";
20
- import BaseSerializer from "./serializer.js";
21
- import ModelProperty from "./model-property.js";
22
- import Record from "./record.js";
23
- import { makeArray } from "@stonyx/utils/object";
24
- import { forEachFileImport } from "@stonyx/utils/file";
25
- import { kebabCaseToPascalCase, pluralize } from "@stonyx/utils/string";
26
- import baseTransforms from "./transforms.js";
17
+ import DB from './db.js';
18
+ import config from 'stonyx/config';
19
+ import log from 'stonyx/log';
20
+ import { forEachFileImport } from '@stonyx/utils/file';
21
+ import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
22
+ import setupRestServer from './setup-rest-server.js';
23
+ import baseTransforms from './transforms.js';
24
+ import Store from './store.js';
25
+ import Serializer from './serializer.js';
26
+
27
+ const defaultOptions = {
28
+ dbType: 'json'
29
+ }
27
30
 
28
31
  export default class Orm {
29
- initialized = false;
32
+ static initialized = false;
33
+ static relationships = new Map();
34
+ static store = new Store();
30
35
 
31
36
  models = {};
32
37
  serializers = {};
33
38
  transforms = { ...baseTransforms };
39
+ warnings = new Set();
34
40
 
35
- constructor() {
41
+ constructor(options={}) {
36
42
  if (Orm.instance) return Orm.instance;
37
43
 
44
+ const { relationships } = Orm;
45
+
46
+ // Declare relationship maps
47
+ for (const key of ['hasMany', 'belongsTo', 'global', 'pending', 'pendingBelongsTo']) {
48
+ relationships.set(key, new Map());
49
+ }
50
+
51
+ this.options = { ...defaultOptions, ...options };
52
+
38
53
  Orm.instance = this;
39
54
  }
40
55
 
41
56
  async init() {
42
- const { paths } = config.orm;
57
+ const { paths, restServer } = config.orm;
43
58
  const promises = ['Model', 'Serializer', 'Transform'].map(type => {
44
59
  const lowerCaseType = type.toLowerCase();
45
60
  const path = paths[lowerCaseType];
61
+
46
62
  if (!path) throw new Error(`Configuration Error: ORM path for "${type}" must be defined.`);
47
63
 
48
64
  return forEachFileImport(path, (exported, { name }) => {
49
65
  // Transforms keep their original name, everything else gets converted to PascalCase with the type suffix
50
66
  const alias = type === 'Transform' ? name : `${kebabCaseToPascalCase(name)}${type}`;
51
67
 
68
+ if (type === 'Model') Orm.store.set(name, new Map());
69
+
52
70
  return this[pluralize(lowerCaseType)][alias] = exported;
53
- }, { ignoreAccessFailure: true, rawName: true });
71
+ }, { ignoreAccessFailure: true, rawName: true, recursive: true, recursiveNaming: true });
54
72
  });
55
73
 
56
- promises.push(new DB().init());
57
-
74
+ // Wait for imports before db & rest server setup
58
75
  await Promise.all(promises);
59
-
60
- this.initialized = true;
76
+
77
+ if (this.options.dbType !== 'none') {
78
+ const db = new DB();
79
+ this.db = db;
80
+
81
+ promises.push(db.init());
82
+ }
83
+
84
+ if (restServer.enabled === 'true') {
85
+ promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
86
+ }
87
+
88
+ Orm.ready = await Promise.all(promises);
89
+ Orm.initialized = true;
90
+ }
91
+
92
+ static get db() {
93
+ if (!Orm.initialized) throw new Error('ORM has not been initialized yet');
94
+
95
+ return Orm.instance.db;
61
96
  }
62
97
 
63
98
  getRecordClasses(modelName) {
@@ -65,36 +100,20 @@ export default class Orm {
65
100
 
66
101
  return {
67
102
  modelClass: this.models[`${modelClassPrefix}Model`],
68
- serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || BaseSerializer
103
+ serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || Serializer
69
104
  };
70
105
  }
71
- }
72
-
73
- export { BaseModel, BaseSerializer };
74
106
 
75
- export function attr() {
76
- return new ModelProperty(...arguments);
77
- }
78
-
79
- export function belongsTo(modelName) {
80
- return rawData => createRecord(modelName, rawData);
81
- }
107
+ // Queue warnings to avoid the same error from being logged in the same iteration
108
+ warn(message) {
109
+ this.warnings.add(message);
82
110
 
83
- export function hasMany(modelName) {
84
- return rawData => makeArray(rawData).map(elementData => createRecord(modelName, elementData));
111
+ setTimeout(() => {
112
+ this.warnings.forEach(warning => log.warn(warning));
113
+ this.warnings.clear();
114
+ }, 0);
115
+ }
85
116
  }
86
117
 
87
- export function createRecord(modelName, rawData={}) {
88
- if (!Orm.instance.initialized) throw new Error("ORM is not ready");
89
-
90
- const { modelClass, serializerClass } = Orm.instance.getRecordClasses(modelName);
91
-
92
- if (!modelClass) throw new Error(`A model named "${modelName}" does not exist`);
93
-
94
- const model = new modelClass(modelName);
95
- const serializer = new serializerClass(model);
96
- const record = new Record(model, serializer);
97
-
98
- record.serialize(rawData);
99
- return record;
100
- }
118
+ export const store = Orm.store;
119
+ export const relationships = Orm.relationships;
@@ -0,0 +1,103 @@
1
+ import Orm, { store, relationships } from '@stonyx/orm';
2
+ import Record from './record.js';
3
+
4
+ const defaultOptions = {
5
+ isDbRecord: false,
6
+ serialize: true,
7
+ transform: true
8
+ };
9
+
10
+ export function createRecord(modelName, rawData={}, userOptions={}) {
11
+ const orm = Orm.instance;
12
+ const { initialized } = Orm;
13
+ const options = { ...defaultOptions, ...userOptions };
14
+
15
+ if (!initialized && !options.isDbRecord) throw new Error('ORM is not ready');
16
+
17
+ const modelStore = store.get(modelName);
18
+ const globalRelationships = relationships.get('global');
19
+ const pendingRelationships = relationships.get('pending');
20
+
21
+ assignRecordId(modelName, rawData);
22
+ if (modelStore.has(rawData.id)) return modelStore.get(rawData.id);
23
+
24
+ const { modelClass, serializerClass } = orm.getRecordClasses(modelName);
25
+
26
+ if (!modelClass) throw new Error(`A model named '${modelName}' does not exist`);
27
+
28
+ const model = new modelClass(modelName);
29
+ const serializer = new serializerClass(model);
30
+ const record = new Record(model, serializer);
31
+
32
+ record.serialize(rawData, options);
33
+ modelStore.set(record.id, record);
34
+
35
+ // populate global hasMany relationships
36
+ const globalHasMany = globalRelationships.get(modelName);
37
+ if (globalHasMany) for (const relationship of globalHasMany) relationship.push(record);
38
+
39
+ // populate pending hasMany relationships and clear the queue
40
+ const pendingHasMany = pendingRelationships.get(modelName)?.get(record.id);
41
+ if (pendingHasMany) {
42
+ for (const relationship of pendingHasMany) relationship.push(record);
43
+ pendingHasMany.splice(0);
44
+ }
45
+
46
+ // Fulfill pending belongsTo relationships
47
+ const pendingBelongsToQueue = relationships.get('pendingBelongsTo');
48
+ const pendingBelongsTo = pendingBelongsToQueue.get(modelName)?.get(record.id);
49
+
50
+ if (pendingBelongsTo) {
51
+ const belongsToReg = relationships.get('belongsTo');
52
+ const hasManyReg = relationships.get('hasMany');
53
+
54
+ for (const { sourceRecord, sourceModelName, relationshipKey, relationshipId } of pendingBelongsTo) {
55
+ // Update the belongsTo relationship on the source record
56
+ sourceRecord.__relationships[relationshipKey] = record;
57
+ sourceRecord[relationshipKey] = record; // Also update the direct property
58
+
59
+ // Update the belongsTo relationship registry
60
+ const sourceModelReg = belongsToReg.get(sourceModelName);
61
+ if (sourceModelReg) {
62
+ const targetModelReg = sourceModelReg.get(modelName);
63
+ if (targetModelReg) {
64
+ targetModelReg.set(relationshipId, record);
65
+ }
66
+ }
67
+
68
+ // Wire inverse hasMany if it exists
69
+ const inverseHasMany = hasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
70
+
71
+ if (inverseHasMany && !inverseHasMany.includes(sourceRecord)) {
72
+ inverseHasMany.push(sourceRecord);
73
+ }
74
+ }
75
+
76
+ // Clear the pending queue
77
+ pendingBelongsTo.length = 0;
78
+ }
79
+
80
+ return record;
81
+ }
82
+
83
+ export function updateRecord(record, rawData, userOptions={}) {
84
+ if (!rawData) throw new Error('rawData must be passed in to updateRecord call');
85
+
86
+ const options = { ...defaultOptions, ...userOptions, update:true };
87
+
88
+ record.serialize(rawData, options);
89
+ }
90
+
91
+ /**
92
+ * gets the next available id based on last record entry.
93
+ *
94
+ * Note/TODO: Records going into a db should get their id from the db instead
95
+ * Atm, i think the best way to do that would be as an id override that happens after the
96
+ * record is created
97
+ */
98
+ function assignRecordId(modelName, rawData) {
99
+ if (rawData.id) return;
100
+
101
+ const modelStore = Array.from(store.get(modelName).values());
102
+ rawData.id = modelStore.length ? modelStore.at(-1).id + 1 : 1;
103
+ }
@@ -0,0 +1,55 @@
1
+ import { Request } from '@stonyx/rest-server';
2
+ import Orm from '@stonyx/orm';
3
+ import config from 'stonyx/config';
4
+ import { dbKey } from './db.js';
5
+
6
+ export default class MetaRequest extends Request {
7
+ constructor() {
8
+ super(...arguments);
9
+
10
+ this.handlers = {
11
+ get: {
12
+ '/meta': () => {
13
+ try {
14
+ const { models } = Orm.instance;
15
+ const metadata = {};
16
+
17
+ for (const [modelName, modelClass] of Object.entries(models)) {
18
+ const name = modelName.slice(0, -5).toLowerCase();
19
+
20
+ if (name === dbKey) continue;
21
+
22
+ const model = new modelClass(modelName);
23
+ const properties = {};
24
+
25
+ // Get regular properties and relationships
26
+ for (const [key, property] of Object.entries(model)) {
27
+ // Skip internal properties
28
+ if (key.startsWith('__')) continue;
29
+
30
+ if (property?.constructor?.name === 'ModelProperty') {
31
+ properties[key] = { type: property.type };
32
+ } else if (typeof property === 'function') {
33
+ const isBelongsTo = property.toString().includes(`getRelationships('belongsTo',`);
34
+ const isHasMany = property.toString().includes(`getRelationships('hasMany',`);
35
+
36
+ if (isBelongsTo || isHasMany) properties[key] = { [isBelongsTo ? 'belongsTo' : 'hasMany']: name };
37
+ }
38
+ }
39
+
40
+ metadata[name] = properties;
41
+ }
42
+
43
+ return metadata;
44
+ } catch (error) {
45
+ return { error: error.message };
46
+ }
47
+ },
48
+ },
49
+ }
50
+ }
51
+
52
+ auth() {
53
+ if (!config.orm.restServer.metaRoute) return 403;
54
+ }
55
+ }
@@ -17,6 +17,13 @@ export default class ModelProperty {
17
17
  }
18
18
 
19
19
  set value(newValue) {
20
+ if (this.ignoreFirstTransform) {
21
+ delete this.ignoreFirstTransform;
22
+ return this._value = newValue;
23
+ }
24
+
25
+ if (newValue === undefined || newValue === null) return;
26
+
20
27
  this._value = Orm.instance.transforms[this.type](newValue);
21
28
  }
22
29
  }
package/src/model.js CHANGED
@@ -1,4 +1,8 @@
1
- export default class BaseModel {
1
+ import { attr } from '@stonyx/orm';
2
+
3
+ export default class Model {
4
+ id = attr('number');
5
+
2
6
  constructor(name) {
3
7
  this.__name = name;
4
8
  }