@stonyx/orm 0.2.1-beta.9 → 0.2.1-beta.90
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 +37 -1
- package/dist/aggregates.d.ts +21 -0
- package/dist/aggregates.js +93 -0
- package/dist/attr.d.ts +2 -0
- package/dist/attr.js +22 -0
- package/dist/belongs-to.d.ts +11 -0
- package/dist/belongs-to.js +59 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +148 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +146 -0
- package/dist/db.d.ts +21 -0
- package/dist/db.js +180 -0
- package/dist/exports/db.d.ts +7 -0
- package/{src → dist}/exports/db.js +2 -4
- package/dist/has-many.d.ts +11 -0
- package/dist/has-many.js +58 -0
- package/dist/hooks.d.ts +62 -0
- package/dist/hooks.js +110 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +34 -0
- package/dist/main.d.ts +46 -0
- package/dist/main.js +181 -0
- package/dist/manage-record.d.ts +13 -0
- package/dist/manage-record.js +123 -0
- package/dist/meta-request.d.ts +6 -0
- package/dist/meta-request.js +52 -0
- package/dist/migrate.d.ts +2 -0
- package/dist/migrate.js +57 -0
- package/dist/model-property.d.ts +9 -0
- package/dist/model-property.js +29 -0
- package/dist/model.d.ts +15 -0
- package/dist/model.js +18 -0
- package/dist/mysql/connection.d.ts +14 -0
- package/dist/mysql/connection.js +24 -0
- package/dist/mysql/migration-generator.d.ts +45 -0
- package/dist/mysql/migration-generator.js +254 -0
- package/dist/mysql/migration-runner.d.ts +12 -0
- package/dist/mysql/migration-runner.js +88 -0
- package/dist/mysql/mysql-db.d.ts +100 -0
- package/dist/mysql/mysql-db.js +425 -0
- package/dist/mysql/query-builder.d.ts +10 -0
- package/dist/mysql/query-builder.js +44 -0
- package/dist/mysql/schema-introspector.d.ts +19 -0
- package/dist/mysql/schema-introspector.js +291 -0
- package/dist/mysql/type-map.d.ts +21 -0
- package/dist/mysql/type-map.js +36 -0
- package/dist/orm-request.d.ts +38 -0
- package/dist/orm-request.js +474 -0
- package/dist/plural-registry.d.ts +4 -0
- package/dist/plural-registry.js +9 -0
- package/dist/postgres/connection.d.ts +15 -0
- package/dist/postgres/connection.js +32 -0
- package/dist/postgres/migration-generator.d.ts +45 -0
- package/dist/postgres/migration-generator.js +261 -0
- package/dist/postgres/migration-runner.d.ts +10 -0
- package/dist/postgres/migration-runner.js +87 -0
- package/dist/postgres/postgres-db.d.ts +119 -0
- package/dist/postgres/postgres-db.js +477 -0
- package/dist/postgres/query-builder.d.ts +27 -0
- package/dist/postgres/query-builder.js +98 -0
- package/dist/postgres/schema-introspector.d.ts +29 -0
- package/dist/postgres/schema-introspector.js +314 -0
- package/dist/postgres/type-map.d.ts +23 -0
- package/dist/postgres/type-map.js +56 -0
- package/dist/record.d.ts +75 -0
- package/dist/record.js +129 -0
- package/dist/relationships.d.ts +10 -0
- package/dist/relationships.js +41 -0
- package/dist/serializer.d.ts +17 -0
- package/dist/serializer.js +136 -0
- package/dist/setup-rest-server.d.ts +1 -0
- package/dist/setup-rest-server.js +52 -0
- package/dist/standalone-db.d.ts +58 -0
- package/dist/standalone-db.js +142 -0
- package/dist/store.d.ts +62 -0
- package/dist/store.js +286 -0
- package/dist/timescale/query-builder.d.ts +43 -0
- package/dist/timescale/query-builder.js +115 -0
- package/dist/timescale/timescale-db.d.ts +45 -0
- package/dist/timescale/timescale-db.js +84 -0
- package/dist/transforms.d.ts +2 -0
- package/dist/transforms.js +17 -0
- package/dist/types/orm-types.d.ts +142 -0
- package/dist/types/orm-types.js +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +17 -0
- package/dist/view-resolver.d.ts +8 -0
- package/dist/view-resolver.js +171 -0
- package/dist/view.d.ts +11 -0
- package/dist/view.js +18 -0
- package/package.json +57 -15
- package/src/aggregates.ts +109 -0
- package/src/{attr.js → attr.ts} +2 -2
- package/src/belongs-to.ts +90 -0
- package/src/cli.ts +183 -0
- package/src/{commands.js → commands.ts} +179 -170
- package/src/{db.js → db.ts} +55 -29
- package/src/exports/db.ts +7 -0
- package/src/has-many.ts +92 -0
- package/src/{hooks.js → hooks.ts} +41 -27
- package/src/{index.js → index.ts} +11 -2
- package/src/main.ts +229 -0
- package/src/manage-record.ts +161 -0
- package/src/{meta-request.js → meta-request.ts} +17 -14
- package/src/{migrate.js → migrate.ts} +9 -9
- package/src/model-property.ts +35 -0
- package/src/model.ts +21 -0
- package/src/mysql/{connection.js → connection.ts} +43 -28
- package/src/mysql/migration-generator.ts +337 -0
- package/src/mysql/{migration-runner.js → migration-runner.ts} +121 -110
- package/src/mysql/mysql-db.ts +543 -0
- package/src/mysql/{query-builder.js → query-builder.ts} +69 -64
- package/src/mysql/schema-introspector.ts +358 -0
- package/src/mysql/{type-map.js → type-map.ts} +42 -37
- package/src/{orm-request.js → orm-request.ts} +186 -108
- package/src/plural-registry.ts +12 -0
- package/src/postgres/connection.ts +48 -0
- package/src/postgres/migration-generator.ts +348 -0
- package/src/postgres/migration-runner.ts +115 -0
- package/src/postgres/postgres-db.ts +616 -0
- package/src/postgres/query-builder.ts +148 -0
- package/src/postgres/schema-introspector.ts +386 -0
- package/src/postgres/type-map.ts +61 -0
- package/src/record.ts +186 -0
- package/src/relationships.ts +54 -0
- package/src/serializer.ts +161 -0
- package/src/{setup-rest-server.js → setup-rest-server.ts} +18 -16
- package/src/standalone-db.ts +185 -0
- package/src/store.ts +373 -0
- package/src/timescale/query-builder.ts +174 -0
- package/src/timescale/timescale-db.ts +119 -0
- package/src/transforms.ts +20 -0
- package/src/types/mysql2.d.ts +49 -0
- package/src/types/orm-types.ts +146 -0
- package/src/types/pg.d.ts +32 -0
- package/src/types/stonyx-cron.d.ts +5 -0
- package/src/types/stonyx-events.d.ts +4 -0
- package/src/types/stonyx-rest-server.d.ts +16 -0
- package/src/types/stonyx-utils.d.ts +33 -0
- package/src/types/stonyx.d.ts +21 -0
- package/src/utils.ts +22 -0
- package/src/view-resolver.ts +211 -0
- package/src/view.ts +22 -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/src/belongs-to.js +0 -63
- package/src/has-many.js +0 -61
- package/src/main.js +0 -148
- package/src/manage-record.js +0 -118
- package/src/model-property.js +0 -29
- package/src/model.js +0 -9
- package/src/mysql/migration-generator.js +0 -188
- package/src/mysql/mysql-db.js +0 -320
- package/src/mysql/schema-introspector.js +0 -158
- package/src/record.js +0 -127
- package/src/relationships.js +0 -43
- package/src/serializer.js +0 -138
- package/src/store.js +0 -211
- package/src/transforms.js +0 -20
- package/src/utils.js +0 -12
- package/test-events-setup.js +0 -41
- package/test-hooks-manual.js +0 -54
- package/test-hooks-with-logging.js +0 -52
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`);
|
|
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`);
|
|
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`);
|
|
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.__model.__name
|
|
319
|
+
)) {
|
|
320
|
+
children.push({ childRecord: value, 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
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Re-export all base PostgreSQL query builders
|
|
2
|
+
export { validateIdentifier, buildInsert, buildUpdate, buildDelete, buildSelect } from '../postgres/query-builder.js';
|
|
3
|
+
|
|
4
|
+
import { validateIdentifier } from '../postgres/query-builder.js';
|
|
5
|
+
|
|
6
|
+
const SAFE_INTERVAL = /^\d+\s+(microsecond|millisecond|second|minute|hour|day|week|month|year)s?$/i;
|
|
7
|
+
|
|
8
|
+
export function validateInterval(interval: string, context: string = 'interval'): string {
|
|
9
|
+
if (!interval || typeof interval !== 'string' || !SAFE_INTERVAL.test(interval.trim())) {
|
|
10
|
+
throw new Error(`Invalid SQL ${context}: "${interval}". Intervals must match pattern like "7 days", "1 hour", "30 minutes".`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return interval.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SAFE_AGGREGATE = /^(COUNT|SUM|AVG|MIN|MAX|FIRST|LAST)\s*\(\s*("?[a-zA-Z_][a-zA-Z0-9_]*"?|\*)\s*\)\s*(AS\s+"?[a-zA-Z_][a-zA-Z0-9_]*"?)?$/i;
|
|
17
|
+
|
|
18
|
+
export function validateAggregate(expr: string, context: string = 'aggregate'): string {
|
|
19
|
+
if (!expr || typeof expr !== 'string' || !SAFE_AGGREGATE.test(expr.trim())) {
|
|
20
|
+
throw new Error(`Invalid SQL ${context}: "${expr}". Aggregates must be simple function calls like "AVG(value) AS avg_value".`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return expr.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface QueryResult {
|
|
27
|
+
sql: string;
|
|
28
|
+
values: unknown[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SqlResult {
|
|
32
|
+
sql: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface HypertableOptions {
|
|
36
|
+
chunkInterval?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface TimeBucketOptions {
|
|
40
|
+
aggregates?: string[];
|
|
41
|
+
where?: Record<string, unknown>;
|
|
42
|
+
orderBy?: string;
|
|
43
|
+
limit?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ContinuousAggregateOptions {
|
|
47
|
+
withNoData?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a CREATE TABLE + hypertable conversion statement.
|
|
52
|
+
* TimescaleDB hypertables are regular tables converted via create_hypertable().
|
|
53
|
+
*/
|
|
54
|
+
export function buildCreateHypertable(table: string, timeColumn: string, options: HypertableOptions = {}): QueryResult {
|
|
55
|
+
validateIdentifier(table, 'table name');
|
|
56
|
+
validateIdentifier(timeColumn, 'column name');
|
|
57
|
+
|
|
58
|
+
const { chunkInterval = '7 days' } = options;
|
|
59
|
+
validateInterval(chunkInterval, 'chunk interval');
|
|
60
|
+
|
|
61
|
+
const sql = `SELECT create_hypertable('"${table}"', '${timeColumn}', chunk_time_interval => INTERVAL '${chunkInterval}', if_not_exists => TRUE)`;
|
|
62
|
+
|
|
63
|
+
return { sql, values: [] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build a time_bucket aggregation query.
|
|
68
|
+
*/
|
|
69
|
+
export function buildTimeBucket(table: string, timeColumn: string, bucketSize: string, options: TimeBucketOptions = {}): QueryResult {
|
|
70
|
+
validateIdentifier(table, 'table name');
|
|
71
|
+
validateIdentifier(timeColumn, 'column name');
|
|
72
|
+
|
|
73
|
+
const { aggregates = [], where, orderBy = 'bucket', limit } = options;
|
|
74
|
+
const values: unknown[] = [];
|
|
75
|
+
let paramIndex = 1;
|
|
76
|
+
|
|
77
|
+
const selectCols: string[] = [`time_bucket($${paramIndex++}, "${timeColumn}") AS bucket`];
|
|
78
|
+
values.push(bucketSize);
|
|
79
|
+
|
|
80
|
+
for (const agg of aggregates) {
|
|
81
|
+
selectCols.push(validateAggregate(agg));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const whereClauses: string[] = [];
|
|
85
|
+
if (where) {
|
|
86
|
+
for (const [k, v] of Object.entries(where)) {
|
|
87
|
+
validateIdentifier(k, 'column name');
|
|
88
|
+
whereClauses.push(`"${k}" = $${paramIndex++}`);
|
|
89
|
+
values.push(v);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const whereStr = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
|
|
94
|
+
let orderStr = '';
|
|
95
|
+
if (orderBy) {
|
|
96
|
+
const parts = orderBy.trim().split(/\s+/);
|
|
97
|
+
const col = parts[0];
|
|
98
|
+
const dir = parts[1]?.toUpperCase();
|
|
99
|
+
|
|
100
|
+
validateIdentifier(col, 'ORDER BY column');
|
|
101
|
+
|
|
102
|
+
if (dir && dir !== 'ASC' && dir !== 'DESC') {
|
|
103
|
+
throw new Error(`Invalid ORDER BY direction: "${dir}". Must be ASC or DESC.`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
orderStr = ` ORDER BY "${col}"${dir ? ` ${dir}` : ''}`;
|
|
107
|
+
}
|
|
108
|
+
let limitStr = '';
|
|
109
|
+
if (limit != null) {
|
|
110
|
+
limitStr = ` LIMIT $${paramIndex++}`;
|
|
111
|
+
values.push(limit);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sql = `SELECT ${selectCols.join(', ')} FROM "${table}"${whereStr} GROUP BY bucket${orderStr}${limitStr}`;
|
|
115
|
+
|
|
116
|
+
return { sql, values };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a continuous aggregate creation statement.
|
|
121
|
+
*/
|
|
122
|
+
export function buildContinuousAggregate(viewName: string, table: string, timeColumn: string, bucketSize: string, aggregates: string[], options: ContinuousAggregateOptions = {}): SqlResult {
|
|
123
|
+
validateIdentifier(viewName, 'view name');
|
|
124
|
+
validateIdentifier(table, 'table name');
|
|
125
|
+
validateIdentifier(timeColumn, 'column name');
|
|
126
|
+
|
|
127
|
+
const { withNoData = false } = options;
|
|
128
|
+
validateInterval(bucketSize, 'bucket size');
|
|
129
|
+
aggregates.forEach(agg => validateAggregate(agg));
|
|
130
|
+
|
|
131
|
+
const selectCols: string[] = [
|
|
132
|
+
`time_bucket('${bucketSize}', "${timeColumn}") AS bucket`,
|
|
133
|
+
...aggregates,
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const withClause = withNoData ? ' WITH NO DATA' : '';
|
|
137
|
+
|
|
138
|
+
const sql = `CREATE MATERIALIZED VIEW "${viewName}" WITH (timescaledb.continuous) AS SELECT ${selectCols.join(', ')} FROM "${table}" GROUP BY bucket${withClause}`;
|
|
139
|
+
|
|
140
|
+
return { sql };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build an ADD compression policy statement.
|
|
145
|
+
*/
|
|
146
|
+
export function buildCompressionPolicy(table: string, compressAfter: string): SqlResult {
|
|
147
|
+
validateIdentifier(table, 'table name');
|
|
148
|
+
validateInterval(compressAfter, 'compress after interval');
|
|
149
|
+
|
|
150
|
+
const sql = `SELECT add_compression_policy('"${table}"', INTERVAL '${compressAfter}', if_not_exists => TRUE)`;
|
|
151
|
+
|
|
152
|
+
return { sql };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build an ALTER TABLE to enable compression on a hypertable.
|
|
157
|
+
*/
|
|
158
|
+
export function buildEnableCompression(table: string, segmentBy?: string, orderBy?: string): SqlResult {
|
|
159
|
+
validateIdentifier(table, 'table name');
|
|
160
|
+
|
|
161
|
+
let opts = `timescaledb.compress`;
|
|
162
|
+
if (segmentBy) {
|
|
163
|
+
validateIdentifier(segmentBy, 'column name');
|
|
164
|
+
opts += `, timescaledb.compress_segmentby = '"${segmentBy}"'`;
|
|
165
|
+
}
|
|
166
|
+
if (orderBy) {
|
|
167
|
+
validateIdentifier(orderBy, 'column name');
|
|
168
|
+
opts += `, timescaledb.compress_orderby = '"${orderBy}"'`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sql = `ALTER TABLE "${table}" SET (${opts})`;
|
|
172
|
+
|
|
173
|
+
return { sql };
|
|
174
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import PostgresDB from '../postgres/postgres-db.js';
|
|
2
|
+
import { isDbError } from '../utils.js';
|
|
3
|
+
import { buildCreateHypertable, buildTimeBucket, buildContinuousAggregate, buildCompressionPolicy, buildEnableCompression } from './query-builder.js';
|
|
4
|
+
|
|
5
|
+
interface HypertableOptions {
|
|
6
|
+
chunkInterval?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface TimeBucketOptions {
|
|
10
|
+
aggregates?: string[];
|
|
11
|
+
where?: Record<string, unknown>;
|
|
12
|
+
orderBy?: string;
|
|
13
|
+
limit?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ContinuousAggregateOptions {
|
|
17
|
+
withNoData?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CompressionOptions {
|
|
21
|
+
segmentBy?: string;
|
|
22
|
+
orderBy?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TimescaleDeps {
|
|
26
|
+
buildCreateHypertable: typeof buildCreateHypertable;
|
|
27
|
+
buildTimeBucket: typeof buildTimeBucket;
|
|
28
|
+
buildContinuousAggregate: typeof buildContinuousAggregate;
|
|
29
|
+
buildEnableCompression: typeof buildEnableCompression;
|
|
30
|
+
buildCompressionPolicy: typeof buildCompressionPolicy;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default class TimescaleDB extends PostgresDB {
|
|
34
|
+
static override extensions: string[] = ['timescaledb'];
|
|
35
|
+
static override configKey: string = 'timescale';
|
|
36
|
+
|
|
37
|
+
constructor(deps: Record<string, unknown> = {}) {
|
|
38
|
+
super({
|
|
39
|
+
...deps,
|
|
40
|
+
buildCreateHypertable,
|
|
41
|
+
buildTimeBucket,
|
|
42
|
+
buildContinuousAggregate,
|
|
43
|
+
buildCompressionPolicy,
|
|
44
|
+
buildEnableCompression,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private get tsDeps(): TimescaleDeps {
|
|
49
|
+
return this.deps as unknown as TimescaleDeps;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Convert a table to a TimescaleDB hypertable.
|
|
54
|
+
* Should be called after the table is created (e.g. after initial migration).
|
|
55
|
+
*/
|
|
56
|
+
async createHypertable(modelName: string, timeColumn: string, options: HypertableOptions = {}): Promise<void> {
|
|
57
|
+
const schemas = this.deps.introspectModels();
|
|
58
|
+
const schema = schemas[modelName];
|
|
59
|
+
if (!schema) throw new Error(`Model '${modelName}' not found`);
|
|
60
|
+
|
|
61
|
+
const { sql } = this.tsDeps.buildCreateHypertable(schema.table, timeColumn, options);
|
|
62
|
+
await this.requirePool().query(sql);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Query time-bucketed aggregations on a hypertable.
|
|
67
|
+
*/
|
|
68
|
+
async timeBucket(modelName: string, timeColumn: string, bucketSize: string, options: TimeBucketOptions = {}): Promise<Record<string, unknown>[]> {
|
|
69
|
+
const schemas = this.deps.introspectModels();
|
|
70
|
+
const schema = schemas[modelName];
|
|
71
|
+
if (!schema) return [];
|
|
72
|
+
|
|
73
|
+
const { sql, values } = this.tsDeps.buildTimeBucket(schema.table, timeColumn, bucketSize, options);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const result = await this.requirePool().query(sql, values);
|
|
77
|
+
return result.rows;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (isDbError(error) && error.code === '42P01') return [];
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a continuous aggregate view on a hypertable.
|
|
86
|
+
*/
|
|
87
|
+
async createContinuousAggregate(viewName: string, modelName: string, timeColumn: string, bucketSize: string, aggregates: string[], options: ContinuousAggregateOptions = {}): Promise<void> {
|
|
88
|
+
const schemas = this.deps.introspectModels();
|
|
89
|
+
const schema = schemas[modelName];
|
|
90
|
+
if (!schema) throw new Error(`Model '${modelName}' not found`);
|
|
91
|
+
|
|
92
|
+
const { sql } = this.tsDeps.buildContinuousAggregate(viewName, schema.table, timeColumn, bucketSize, aggregates, options);
|
|
93
|
+
await this.requirePool().query(sql);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Enable compression on a hypertable.
|
|
98
|
+
*/
|
|
99
|
+
async enableCompression(modelName: string, options: CompressionOptions = {}): Promise<void> {
|
|
100
|
+
const schemas = this.deps.introspectModels();
|
|
101
|
+
const schema = schemas[modelName];
|
|
102
|
+
if (!schema) throw new Error(`Model '${modelName}' not found`);
|
|
103
|
+
|
|
104
|
+
const { sql } = this.tsDeps.buildEnableCompression(schema.table, options.segmentBy, options.orderBy);
|
|
105
|
+
await this.requirePool().query(sql);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Add a compression policy to a hypertable.
|
|
110
|
+
*/
|
|
111
|
+
async addCompressionPolicy(modelName: string, compressAfter: string): Promise<void> {
|
|
112
|
+
const schemas = this.deps.introspectModels();
|
|
113
|
+
const schema = schemas[modelName];
|
|
114
|
+
if (!schema) throw new Error(`Model '${modelName}' not found`);
|
|
115
|
+
|
|
116
|
+
const { sql } = this.tsDeps.buildCompressionPolicy(schema.table, compressAfter);
|
|
117
|
+
await this.requirePool().query(sql);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getTimestamp } from "@stonyx/utils/date";
|
|
2
|
+
|
|
3
|
+
const transforms: Record<string, (value: unknown) => unknown> = {
|
|
4
|
+
boolean: (value: unknown) => typeof value === 'string' ? (value as string).trim().toLowerCase() === 'true' : !!value,
|
|
5
|
+
date: (value: unknown) => value ? new Date(value as string | number) : null,
|
|
6
|
+
float: (value: unknown) => parseFloat(value as string),
|
|
7
|
+
number: (value: unknown) => parseInt(value as string),
|
|
8
|
+
passthrough: (value: unknown) => value,
|
|
9
|
+
string: (value: unknown) => String(value),
|
|
10
|
+
timestamp: (value: unknown) => getTimestamp(value as string | number | Date | undefined),
|
|
11
|
+
trim: (value: unknown) => (value as string)?.trim(),
|
|
12
|
+
uppercase: (value: unknown) => (value as string)?.toUpperCase(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Math Proxies
|
|
16
|
+
(['ceil', 'floor', 'round'] as const).forEach(method => {
|
|
17
|
+
transforms[method] = (value: unknown) => Math[method](value as number);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export default transforms;
|