sqlite-zod-orm 3.0.0 → 3.2.1
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 +329 -207
- package/dist/index.js +5014 -0
- package/package.json +13 -13
- package/src/build.ts +8 -10
- package/src/database.ts +496 -0
- package/src/index.ts +24 -0
- package/src/proxy-query.ts +55 -51
- package/src/query-builder.ts +152 -7
- package/src/schema.ts +122 -0
- package/src/types.ts +195 -0
- package/src/satidb.ts +0 -1153
package/src/satidb.ts
DELETED
|
@@ -1,1153 +0,0 @@
|
|
|
1
|
-
import { Database } from 'bun:sqlite';
|
|
2
|
-
import { EventEmitter } from 'events';
|
|
3
|
-
import { z } from 'zod';
|
|
4
|
-
import { QueryBuilder } from './query-builder';
|
|
5
|
-
import { executeProxyQuery, type ProxyQueryResult } from './proxy-query';
|
|
6
|
-
|
|
7
|
-
/** Fluent update builder: `db.users.update({ level: 10 }).where({ name: 'Alice' }).exec()` */
|
|
8
|
-
export type UpdateBuilder<T> = {
|
|
9
|
-
/** Set filter conditions for the update */
|
|
10
|
-
where: (conditions: Record<string, any>) => UpdateBuilder<T>;
|
|
11
|
-
/** Execute the update and return the number of rows affected */
|
|
12
|
-
exec: () => number;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
type ZodType = z.ZodTypeAny;
|
|
16
|
-
type SchemaMap = Record<string, z.ZodType<any>>;
|
|
17
|
-
|
|
18
|
-
/** Internal cast: all schemas are z.object() at runtime, but may be typed as z.ZodType<T> */
|
|
19
|
-
const asZodObject = (s: z.ZodType<any>) => s as unknown as z.ZodObject<any>;
|
|
20
|
-
|
|
21
|
-
/** Index definition: single column or composite columns */
|
|
22
|
-
type IndexDef = string | string[];
|
|
23
|
-
|
|
24
|
-
/** Options for SatiDB constructor */
|
|
25
|
-
type SatiDBOptions = {
|
|
26
|
-
/** Enable trigger-based change tracking for efficient subscribe polling */
|
|
27
|
-
changeTracking?: boolean;
|
|
28
|
-
/** Index definitions per table: { tableName: ['col1', ['col2', 'col3']] } */
|
|
29
|
-
indexes?: Record<string, IndexDef[]>;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
type Relationship = {
|
|
33
|
-
type: 'belongs-to' | 'one-to-many';
|
|
34
|
-
from: string;
|
|
35
|
-
to: string;
|
|
36
|
-
relationshipField: string;
|
|
37
|
-
foreignKey: string;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
type LazyMethod<T = any, R = any> = {
|
|
41
|
-
name: string;
|
|
42
|
-
type: Relationship['type'];
|
|
43
|
-
fetch: (entity: T) => R;
|
|
44
|
-
childEntityName?: string;
|
|
45
|
-
parentEntityName?: string;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// --- Type Helpers for Stronger Safety ---
|
|
49
|
-
type InferSchema<S extends z.ZodType<any>> = z.infer<S>;
|
|
50
|
-
|
|
51
|
-
type EntityData<S extends z.ZodType<any>> = Omit<InferSchema<S>, 'id'>;
|
|
52
|
-
|
|
53
|
-
type AugmentedEntity<S extends z.ZodType<any>> = InferSchema<S> & {
|
|
54
|
-
update: (data: Partial<EntityData<S>>) => AugmentedEntity<S> | null;
|
|
55
|
-
delete: () => void;
|
|
56
|
-
[key: string]: any;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
type OneToManyRelationship<S extends z.ZodType<any>> = {
|
|
60
|
-
insert: (data: EntityData<S>) => AugmentedEntity<S>;
|
|
61
|
-
get: (conditions: number | Partial<InferSchema<S>>) => AugmentedEntity<S> | null;
|
|
62
|
-
/** Update by ID: `update(id, data)`. Fluent: `update(data).where(filter).exec()` */
|
|
63
|
-
update: ((id: number, data: Partial<EntityData<S>>) => AugmentedEntity<S> | null) & ((data: Partial<EntityData<S>>) => UpdateBuilder<AugmentedEntity<S>>);
|
|
64
|
-
upsert: (conditions?: Partial<InferSchema<S>>, data?: Partial<InferSchema<S>>) => AugmentedEntity<S>;
|
|
65
|
-
delete: (id?: number) => void;
|
|
66
|
-
subscribe: (event: 'insert' | 'update' | 'delete', callback: (data: AugmentedEntity<S>) => void) => void;
|
|
67
|
-
unsubscribe: (event: 'insert' | 'update' | 'delete', callback: (data: AugmentedEntity<S>) => void) => void;
|
|
68
|
-
push: (data: EntityData<S>) => AugmentedEntity<S>;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
type EntityAccessor<S extends z.ZodType<any>> = {
|
|
72
|
-
insert: (data: EntityData<S>) => AugmentedEntity<S>;
|
|
73
|
-
get: (conditions: number | Partial<InferSchema<S>>) => AugmentedEntity<S> | null;
|
|
74
|
-
/** Update by ID: `update(id, data)`. Fluent: `update(data).where(filter).exec()` */
|
|
75
|
-
update: ((id: number, data: Partial<EntityData<S>>) => AugmentedEntity<S> | null) & ((data: Partial<EntityData<S>>) => UpdateBuilder<AugmentedEntity<S>>);
|
|
76
|
-
upsert: (conditions?: Partial<InferSchema<S>>, data?: Partial<InferSchema<S>>) => AugmentedEntity<S>;
|
|
77
|
-
delete: (id: number) => void;
|
|
78
|
-
subscribe: (event: 'insert' | 'update' | 'delete', callback: (data: AugmentedEntity<S>) => void) => void;
|
|
79
|
-
unsubscribe: (event: 'insert' | 'update' | 'delete', callback: (data: AugmentedEntity<S>) => void) => void;
|
|
80
|
-
/** Fluent query builder: `db.users.select().where({ level: 10 }).limit(5).all()` */
|
|
81
|
-
select: (...cols: (keyof InferSchema<S> & string)[]) => QueryBuilder<AugmentedEntity<S>>;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
type TypedAccessors<T extends SchemaMap> = {
|
|
85
|
-
[K in keyof T]: EntityAccessor<T[K]>;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* A custom SQLite database wrapper with schema validation, relationships, and event handling.
|
|
90
|
-
*/
|
|
91
|
-
class _SatiDB<Schemas extends SchemaMap> extends EventEmitter {
|
|
92
|
-
private db: Database;
|
|
93
|
-
private schemas: Schemas;
|
|
94
|
-
private relationships: Relationship[];
|
|
95
|
-
private lazyMethods: Record<string, LazyMethod[]>;
|
|
96
|
-
private subscriptions: Record<'insert' | 'update' | 'delete', Record<string, ((data: any) => void)[]>>;
|
|
97
|
-
private options: SatiDBOptions;
|
|
98
|
-
|
|
99
|
-
constructor(dbFile: string, schemas: Schemas, options: SatiDBOptions = {}) {
|
|
100
|
-
super();
|
|
101
|
-
this.db = new Database(dbFile);
|
|
102
|
-
this.db.run('PRAGMA foreign_keys = ON');
|
|
103
|
-
this.schemas = schemas;
|
|
104
|
-
this.options = options;
|
|
105
|
-
this.subscriptions = { insert: {}, update: {}, delete: {} };
|
|
106
|
-
this.relationships = this.parseRelationships(schemas);
|
|
107
|
-
this.lazyMethods = this.buildLazyMethods();
|
|
108
|
-
this.initializeTables();
|
|
109
|
-
this.runMigrations();
|
|
110
|
-
if (options.indexes) this.createIndexes(options.indexes);
|
|
111
|
-
if (options.changeTracking) this.setupChangeTracking();
|
|
112
|
-
|
|
113
|
-
Object.keys(schemas).forEach(entityName => {
|
|
114
|
-
const key = entityName as keyof Schemas;
|
|
115
|
-
const accessor: EntityAccessor<Schemas[typeof key]> = {
|
|
116
|
-
insert: (data) => this.insert(entityName, data),
|
|
117
|
-
get: (conditions) => this.get(entityName, conditions),
|
|
118
|
-
update: (idOrData: any, data?: any) => {
|
|
119
|
-
// update(id, data) → direct update by ID
|
|
120
|
-
if (typeof idOrData === 'number') return this.update(entityName, idOrData, data);
|
|
121
|
-
// update(data) → return UpdateBuilder
|
|
122
|
-
return this._createUpdateBuilder(entityName, idOrData);
|
|
123
|
-
},
|
|
124
|
-
upsert: (conditions, data) => this.upsert(entityName, data, conditions),
|
|
125
|
-
delete: (id) => this.delete(entityName, id),
|
|
126
|
-
subscribe: (event, callback) => this.subscribe(event, entityName, callback),
|
|
127
|
-
unsubscribe: (event, callback) => this.unsubscribe(event, entityName, callback),
|
|
128
|
-
select: (...cols: string[]) => this._createQueryBuilder(entityName, cols),
|
|
129
|
-
};
|
|
130
|
-
(this as any)[key] = accessor;
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
private parseRelationships(schemas: SchemaMap): Relationship[] {
|
|
135
|
-
const relationships: Relationship[] = [];
|
|
136
|
-
for (const [entityName, schema] of Object.entries(schemas)) {
|
|
137
|
-
const shape = asZodObject(schema).shape as Record<string, ZodType>;
|
|
138
|
-
for (const [fieldName, fieldSchema] of Object.entries(shape)) {
|
|
139
|
-
let actualSchema = fieldSchema;
|
|
140
|
-
if (actualSchema instanceof z.ZodOptional) {
|
|
141
|
-
actualSchema = actualSchema._def.innerType;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (actualSchema instanceof z.ZodLazy) {
|
|
145
|
-
const lazySchema = actualSchema._def.getter();
|
|
146
|
-
let relType: 'belongs-to' | 'one-to-many' | null = null;
|
|
147
|
-
let targetSchema: z.ZodObject<any> | null = null;
|
|
148
|
-
|
|
149
|
-
if (lazySchema instanceof z.ZodArray) {
|
|
150
|
-
relType = 'one-to-many';
|
|
151
|
-
targetSchema = lazySchema._def.type;
|
|
152
|
-
} else {
|
|
153
|
-
relType = 'belongs-to';
|
|
154
|
-
targetSchema = lazySchema;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (relType && targetSchema) {
|
|
158
|
-
const targetEntityName = Object.keys(schemas).find(
|
|
159
|
-
name => schemas[name] === targetSchema
|
|
160
|
-
);
|
|
161
|
-
if (targetEntityName) {
|
|
162
|
-
const foreignKey = relType === 'belongs-to' ? `${fieldName}Id` : '';
|
|
163
|
-
relationships.push({
|
|
164
|
-
type: relType,
|
|
165
|
-
from: entityName,
|
|
166
|
-
to: targetEntityName,
|
|
167
|
-
relationshipField: fieldName,
|
|
168
|
-
foreignKey,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
return relationships;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
private buildLazyMethods(): Record<string, LazyMethod[]> {
|
|
179
|
-
const lazyMethods: Record<string, LazyMethod[]> = {};
|
|
180
|
-
|
|
181
|
-
for (const rel of this.relationships) {
|
|
182
|
-
lazyMethods[rel.from] = lazyMethods[rel.from] || [];
|
|
183
|
-
|
|
184
|
-
if (rel.type === 'one-to-many') {
|
|
185
|
-
const belongsToRel = this.relationships.find(r =>
|
|
186
|
-
r.type === 'belongs-to' &&
|
|
187
|
-
r.from === rel.to &&
|
|
188
|
-
r.to === rel.from
|
|
189
|
-
);
|
|
190
|
-
if (!belongsToRel) throw new Error(`No 'belongs-to' relationship found for one-to-many from ${rel.from} to ${rel.to}`);
|
|
191
|
-
const foreignKeyInChild = belongsToRel.foreignKey;
|
|
192
|
-
lazyMethods[rel.from].push({
|
|
193
|
-
name: rel.relationshipField,
|
|
194
|
-
type: 'one-to-many',
|
|
195
|
-
childEntityName: rel.to,
|
|
196
|
-
parentEntityName: rel.from,
|
|
197
|
-
fetch: (entity) => ({
|
|
198
|
-
insert: (data: any) => this.insert(rel.to, { ...data, [foreignKeyInChild]: entity.id }),
|
|
199
|
-
get: (conditions: any) => {
|
|
200
|
-
const queryConditions = typeof conditions === 'number' ? { id: conditions } : conditions;
|
|
201
|
-
return this.get(rel.to, { ...queryConditions, [foreignKeyInChild]: entity.id });
|
|
202
|
-
},
|
|
203
|
-
findOne: (conditions: any) => this.findOne(rel.to, { ...conditions, [foreignKeyInChild]: entity.id }),
|
|
204
|
-
find: (conditions: any = {}) => this.find(rel.to, { ...conditions, [foreignKeyInChild]: entity.id }),
|
|
205
|
-
update: (id: number, data: any) => this.update(rel.to, id, data),
|
|
206
|
-
upsert: (conditions: any = {}, data: any = {}) => this.upsert(rel.to, { ...data, [foreignKeyInChild]: entity.id }, { ...conditions, [foreignKeyInChild]: entity.id }),
|
|
207
|
-
delete: (id?: number) => {
|
|
208
|
-
if (id) {
|
|
209
|
-
this.delete(rel.to, id);
|
|
210
|
-
} else {
|
|
211
|
-
const relatedEntities = this.find(rel.to, { [foreignKeyInChild]: entity.id });
|
|
212
|
-
relatedEntities.forEach(e => this.delete(rel.to, e.id));
|
|
213
|
-
}
|
|
214
|
-
},
|
|
215
|
-
subscribe: (event: any, callback: any) => this.subscribe(event, rel.to, callback),
|
|
216
|
-
unsubscribe: (event: any, callback: any) => this.unsubscribe(event, rel.to, callback),
|
|
217
|
-
push: (data: any) => this.insert(rel.to, { ...data, [foreignKeyInChild]: entity.id }),
|
|
218
|
-
}),
|
|
219
|
-
});
|
|
220
|
-
} else if (rel.type === 'belongs-to') {
|
|
221
|
-
lazyMethods[rel.from].push({
|
|
222
|
-
name: rel.relationshipField,
|
|
223
|
-
type: 'belongs-to',
|
|
224
|
-
fetch: (entity) => {
|
|
225
|
-
const relatedId = entity[rel.foreignKey];
|
|
226
|
-
return () => (relatedId ? this.get(rel.to, { id: relatedId }) : null);
|
|
227
|
-
},
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
const inverseName = rel.from;
|
|
231
|
-
lazyMethods[rel.to] = lazyMethods[rel.to] || [];
|
|
232
|
-
const parentRel = this.relationships.find(r => r.type === 'one-to-many' && r.from === rel.to && r.to === rel.from);
|
|
233
|
-
if (!parentRel) throw new Error(`No one-to-many relationship found for inverse from ${rel.to} to ${rel.from}`);
|
|
234
|
-
const inverseRelationshipField = parentRel.relationshipField;
|
|
235
|
-
const belongsToRel = this.relationships.find(r =>
|
|
236
|
-
r.type === 'belongs-to' &&
|
|
237
|
-
r.from === rel.from &&
|
|
238
|
-
r.to === rel.to
|
|
239
|
-
);
|
|
240
|
-
if (!belongsToRel) throw new Error(`No 'belongs-to' relationship found for ${rel.from} to ${rel.to}`);
|
|
241
|
-
const foreignKeyInChild = belongsToRel.foreignKey;
|
|
242
|
-
|
|
243
|
-
if (!lazyMethods[rel.to].some(m => m.name === inverseRelationshipField)) {
|
|
244
|
-
lazyMethods[rel.to].push({
|
|
245
|
-
name: inverseRelationshipField,
|
|
246
|
-
type: 'one-to-many',
|
|
247
|
-
childEntityName: rel.from,
|
|
248
|
-
parentEntityName: rel.to,
|
|
249
|
-
fetch: (entity) => ({
|
|
250
|
-
insert: (data: any) => this.insert(rel.from, { ...data, [foreignKeyInChild]: entity.id }),
|
|
251
|
-
get: (conditions: any) => {
|
|
252
|
-
const queryConditions = typeof conditions === 'number' ? { id: conditions } : conditions;
|
|
253
|
-
return this.get(rel.from, { ...queryConditions, [foreignKeyInChild]: entity.id });
|
|
254
|
-
},
|
|
255
|
-
findOne: (conditions: any) => this.findOne(rel.from, { ...conditions, [foreignKeyInChild]: entity.id }),
|
|
256
|
-
find: (conditions: any = {}) => this.find(rel.from, { ...conditions, [foreignKeyInChild]: entity.id }),
|
|
257
|
-
update: (id: number, data: any) => this.update(rel.from, id, data),
|
|
258
|
-
upsert: (conditions: any = {}, data: any = {}) => this.upsert(rel.from, { ...data, [foreignKeyInChild]: entity.id }, { ...conditions, [foreignKeyInChild]: entity.id }),
|
|
259
|
-
delete: (id?: number) => {
|
|
260
|
-
if (id) {
|
|
261
|
-
this.delete(rel.from, id);
|
|
262
|
-
} else {
|
|
263
|
-
const relatedEntities = this.find(rel.from, { [foreignKeyInChild]: entity.id });
|
|
264
|
-
relatedEntities.forEach(e => this.delete(rel.from, e.id));
|
|
265
|
-
}
|
|
266
|
-
},
|
|
267
|
-
subscribe: (event: any, callback: any) => this.subscribe(event, rel.from, callback),
|
|
268
|
-
unsubscribe: (event: any, callback: any) => this.unsubscribe(event, rel.from, callback),
|
|
269
|
-
push: (data: any) => this.insert(rel.from, { ...data, [foreignKeyInChild]: entity.id }),
|
|
270
|
-
}),
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
return lazyMethods;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
private initializeTables(): void {
|
|
279
|
-
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
280
|
-
const storableFields = this.getStorableFields(schema);
|
|
281
|
-
const storableFieldNames = new Set(storableFields.map(f => f.name));
|
|
282
|
-
let columnDefs = storableFields.map(f => `${f.name} ${this.zodTypeToSqlType(f.type)}`);
|
|
283
|
-
let constraints = [];
|
|
284
|
-
|
|
285
|
-
const belongsToRels = this.relationships.filter(
|
|
286
|
-
rel => rel.type === 'belongs-to' && rel.from === entityName
|
|
287
|
-
);
|
|
288
|
-
for (const rel of belongsToRels) {
|
|
289
|
-
if (!storableFieldNames.has(rel.foreignKey)) {
|
|
290
|
-
columnDefs.push(`${rel.foreignKey} INTEGER`);
|
|
291
|
-
}
|
|
292
|
-
constraints.push(`FOREIGN KEY (${rel.foreignKey}) REFERENCES ${rel.to}(id) ON DELETE SET NULL`);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const createTableSql = `CREATE TABLE IF NOT EXISTS ${entityName} (id INTEGER PRIMARY KEY AUTOINCREMENT, ${columnDefs.join(', ')}${constraints.length > 0 ? ', ' + constraints.join(', ') : ''})`;
|
|
296
|
-
this.db.run(createTableSql);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// ================== Migrations ==================
|
|
301
|
-
|
|
302
|
-
private runMigrations(): void {
|
|
303
|
-
// Create meta table to track schema state
|
|
304
|
-
this.db.run(`CREATE TABLE IF NOT EXISTS _sati_meta (
|
|
305
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
306
|
-
table_name TEXT NOT NULL,
|
|
307
|
-
column_name TEXT NOT NULL,
|
|
308
|
-
added_at TEXT DEFAULT (datetime('now')),
|
|
309
|
-
UNIQUE(table_name, column_name)
|
|
310
|
-
)`);
|
|
311
|
-
|
|
312
|
-
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
313
|
-
// Get existing columns from SQLite
|
|
314
|
-
const existingCols = new Set(
|
|
315
|
-
(this.db.query(`PRAGMA table_info(${entityName})`).all() as any[])
|
|
316
|
-
.map(c => c.name)
|
|
317
|
-
);
|
|
318
|
-
|
|
319
|
-
// Get expected columns from schema
|
|
320
|
-
const storableFields = this.getStorableFields(schema);
|
|
321
|
-
const belongsToRels = this.relationships.filter(
|
|
322
|
-
rel => rel.type === 'belongs-to' && rel.from === entityName
|
|
323
|
-
);
|
|
324
|
-
const fkColumns = belongsToRels.map(rel => rel.foreignKey);
|
|
325
|
-
|
|
326
|
-
// Add missing columns
|
|
327
|
-
for (const field of storableFields) {
|
|
328
|
-
if (!existingCols.has(field.name)) {
|
|
329
|
-
const sqlType = this.zodTypeToSqlType(field.type);
|
|
330
|
-
this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${sqlType}`);
|
|
331
|
-
this.db.query(`INSERT OR IGNORE INTO _sati_meta (table_name, column_name) VALUES (?, ?)`).run(entityName, field.name);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
for (const fk of fkColumns) {
|
|
335
|
-
if (!existingCols.has(fk)) {
|
|
336
|
-
this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${fk} INTEGER`);
|
|
337
|
-
this.db.query(`INSERT OR IGNORE INTO _sati_meta (table_name, column_name) VALUES (?, ?)`).run(entityName, fk);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// ================== Indexes ==================
|
|
344
|
-
|
|
345
|
-
private createIndexes(indexDefs: Record<string, IndexDef[]>): void {
|
|
346
|
-
for (const [tableName, indexes] of Object.entries(indexDefs)) {
|
|
347
|
-
if (!this.schemas[tableName]) {
|
|
348
|
-
throw new Error(`Cannot create index on unknown table '${tableName}'`);
|
|
349
|
-
}
|
|
350
|
-
for (const indexDef of indexes) {
|
|
351
|
-
const columns = Array.isArray(indexDef) ? indexDef : [indexDef];
|
|
352
|
-
const indexName = `idx_${tableName}_${columns.join('_')}`;
|
|
353
|
-
const columnList = columns.join(', ');
|
|
354
|
-
this.db.run(`CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${columnList})`);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// ================== Change Tracking ==================
|
|
360
|
-
|
|
361
|
-
private setupChangeTracking(): void {
|
|
362
|
-
// Create the change-tracking table
|
|
363
|
-
this.db.run(`CREATE TABLE IF NOT EXISTS _sati_changes (
|
|
364
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
365
|
-
table_name TEXT NOT NULL,
|
|
366
|
-
row_id INTEGER NOT NULL,
|
|
367
|
-
action TEXT NOT NULL CHECK(action IN ('INSERT', 'UPDATE', 'DELETE')),
|
|
368
|
-
changed_at TEXT DEFAULT (datetime('now'))
|
|
369
|
-
)`);
|
|
370
|
-
|
|
371
|
-
// Create an index for efficient polling
|
|
372
|
-
this.db.run(`CREATE INDEX IF NOT EXISTS idx_sati_changes_table ON _sati_changes (table_name, id)`);
|
|
373
|
-
|
|
374
|
-
// Create triggers for each entity table
|
|
375
|
-
for (const entityName of Object.keys(this.schemas)) {
|
|
376
|
-
// INSERT trigger
|
|
377
|
-
this.db.run(`CREATE TRIGGER IF NOT EXISTS _sati_trg_${entityName}_insert
|
|
378
|
-
AFTER INSERT ON ${entityName}
|
|
379
|
-
BEGIN
|
|
380
|
-
INSERT INTO _sati_changes (table_name, row_id, action) VALUES ('${entityName}', NEW.id, 'INSERT');
|
|
381
|
-
END`);
|
|
382
|
-
|
|
383
|
-
// UPDATE trigger
|
|
384
|
-
this.db.run(`CREATE TRIGGER IF NOT EXISTS _sati_trg_${entityName}_update
|
|
385
|
-
AFTER UPDATE ON ${entityName}
|
|
386
|
-
BEGIN
|
|
387
|
-
INSERT INTO _sati_changes (table_name, row_id, action) VALUES ('${entityName}', NEW.id, 'UPDATE');
|
|
388
|
-
END`);
|
|
389
|
-
|
|
390
|
-
// DELETE trigger
|
|
391
|
-
this.db.run(`CREATE TRIGGER IF NOT EXISTS _sati_trg_${entityName}_delete
|
|
392
|
-
AFTER DELETE ON ${entityName}
|
|
393
|
-
BEGIN
|
|
394
|
-
INSERT INTO _sati_changes (table_name, row_id, action) VALUES ('${entityName}', OLD.id, 'DELETE');
|
|
395
|
-
END`);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Get the latest change sequence number for a table.
|
|
401
|
-
* Used by QueryBuilder.subscribe to efficiently detect changes.
|
|
402
|
-
*/
|
|
403
|
-
public getChangeSeq(tableName?: string): number {
|
|
404
|
-
if (!this.options.changeTracking) return -1;
|
|
405
|
-
const sql = tableName
|
|
406
|
-
? `SELECT MAX(id) as seq FROM _sati_changes WHERE table_name = ?`
|
|
407
|
-
: `SELECT MAX(id) as seq FROM _sati_changes`;
|
|
408
|
-
const params = tableName ? [tableName] : [];
|
|
409
|
-
const row = this.db.query(sql).get(...params) as any;
|
|
410
|
-
return row?.seq ?? 0;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Get changes since a given sequence number.
|
|
415
|
-
*/
|
|
416
|
-
public getChangesSince(sinceSeq: number, tableName?: string): { id: number; table_name: string; row_id: number; action: string; changed_at: string }[] {
|
|
417
|
-
const sql = tableName
|
|
418
|
-
? `SELECT * FROM _sati_changes WHERE id > ? AND table_name = ? ORDER BY id ASC`
|
|
419
|
-
: `SELECT * FROM _sati_changes WHERE id > ? ORDER BY id ASC`;
|
|
420
|
-
const params = tableName ? [sinceSeq, tableName] : [sinceSeq];
|
|
421
|
-
return this.db.query(sql).all(...params) as any[];
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
private isRelationshipField(schema: z.ZodType<any>, key: string): boolean {
|
|
425
|
-
let fieldSchema = asZodObject(schema).shape[key];
|
|
426
|
-
if (fieldSchema instanceof z.ZodOptional) {
|
|
427
|
-
fieldSchema = fieldSchema._def.innerType;
|
|
428
|
-
}
|
|
429
|
-
return fieldSchema instanceof z.ZodLazy;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
private getStorableFields(schema: z.ZodType<any>): { name: string; type: ZodType }[] {
|
|
433
|
-
return Object.entries(asZodObject(schema).shape)
|
|
434
|
-
.filter(([key]) => key !== 'id' && !this.isRelationshipField(schema, key))
|
|
435
|
-
.map(([name, type]) => ({ name, type: type as ZodType }));
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
private zodTypeToSqlType(zodType: ZodType): string {
|
|
439
|
-
if (zodType instanceof z.ZodOptional) {
|
|
440
|
-
zodType = zodType._def.innerType;
|
|
441
|
-
}
|
|
442
|
-
if (zodType instanceof z.ZodDefault) {
|
|
443
|
-
zodType = zodType._def.innerType;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (zodType instanceof z.ZodString || zodType instanceof z.ZodDate) return 'TEXT';
|
|
447
|
-
if (zodType instanceof z.ZodNumber || zodType instanceof z.ZodBoolean) return 'INTEGER';
|
|
448
|
-
if (zodType._def.typeName === 'ZodInstanceOf' && zodType._def.type === Buffer) return 'BLOB';
|
|
449
|
-
return 'TEXT';
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
private transformForStorage(data: Record<string, any>): Record<string, any> {
|
|
453
|
-
const transformed: Record<string, any> = {};
|
|
454
|
-
for (const [key, value] of Object.entries(data)) {
|
|
455
|
-
if (value instanceof Date) {
|
|
456
|
-
transformed[key] = value.toISOString();
|
|
457
|
-
} else if (typeof value === 'boolean') {
|
|
458
|
-
transformed[key] = value ? 1 : 0;
|
|
459
|
-
} else {
|
|
460
|
-
transformed[key] = value;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
return transformed;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
private transformFromStorage(row: Record<string, any>, schema: z.ZodType<any>): Record<string, any> {
|
|
467
|
-
const transformed: Record<string, any> = {};
|
|
468
|
-
for (const [key, value] of Object.entries(row)) {
|
|
469
|
-
let fieldSchema = asZodObject(schema).shape[key];
|
|
470
|
-
if (fieldSchema instanceof z.ZodOptional) {
|
|
471
|
-
fieldSchema = fieldSchema._def.innerType;
|
|
472
|
-
}
|
|
473
|
-
if (fieldSchema instanceof z.ZodDefault) {
|
|
474
|
-
fieldSchema = fieldSchema._def.innerType;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
if (fieldSchema instanceof z.ZodDate && typeof value === 'string') {
|
|
478
|
-
transformed[key] = new Date(value);
|
|
479
|
-
} else if (fieldSchema instanceof z.ZodBoolean && typeof value === 'number') {
|
|
480
|
-
transformed[key] = value === 1;
|
|
481
|
-
} else {
|
|
482
|
-
transformed[key] = value;
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
return transformed;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
private _attachMethods<T extends Record<string, any>>(entityName: string, entity: T, includedData?: Record<string, any>): AugmentedEntity<any> {
|
|
489
|
-
const augmentedEntity = entity as any;
|
|
490
|
-
augmentedEntity.update = (data: Partial<Omit<T, 'id'>>) => this.update(entityName, entity.id, data);
|
|
491
|
-
augmentedEntity.delete = () => this.delete(entityName, entity.id);
|
|
492
|
-
|
|
493
|
-
const lazyMethodDefs = this.lazyMethods[entityName] || [];
|
|
494
|
-
for (const methodDef of lazyMethodDefs) {
|
|
495
|
-
if (includedData && includedData[methodDef.name] !== undefined) {
|
|
496
|
-
if (methodDef.type === 'belongs-to') {
|
|
497
|
-
const includedEntity = includedData[methodDef.name];
|
|
498
|
-
augmentedEntity[methodDef.name] = () => includedEntity;
|
|
499
|
-
} else if (methodDef.type === 'one-to-many') {
|
|
500
|
-
const includedEntities = includedData[methodDef.name] || [];
|
|
501
|
-
const belongsToRel = this.relationships.find(r =>
|
|
502
|
-
r.type === 'belongs-to' &&
|
|
503
|
-
r.from === methodDef.childEntityName! &&
|
|
504
|
-
r.to === methodDef.parentEntityName!
|
|
505
|
-
);
|
|
506
|
-
if (!belongsToRel) throw new Error(`No 'belongs-to' relationship found for one-to-many from ${methodDef.parentEntityName!} to ${methodDef.childEntityName!}`);
|
|
507
|
-
const foreignKeyInChild = belongsToRel.foreignKey;
|
|
508
|
-
|
|
509
|
-
augmentedEntity[methodDef.name] = {
|
|
510
|
-
insert: (data: any) => this.insert(methodDef.childEntityName!, { ...data, [foreignKeyInChild]: entity.id }),
|
|
511
|
-
get: (conditions: any) => {
|
|
512
|
-
const queryConditions = typeof conditions === 'number' ? { id: conditions } : conditions;
|
|
513
|
-
return this.get(methodDef.childEntityName!, { ...queryConditions, [foreignKeyInChild]: entity.id });
|
|
514
|
-
},
|
|
515
|
-
findOne: (conditions: any = {}) => {
|
|
516
|
-
return this.findOne(methodDef.childEntityName!, { ...conditions, [foreignKeyInChild]: entity.id });
|
|
517
|
-
},
|
|
518
|
-
find: (conditions: any = {}) => {
|
|
519
|
-
if (Object.keys(conditions).length === 0) {
|
|
520
|
-
return includedEntities;
|
|
521
|
-
}
|
|
522
|
-
return this.find(methodDef.childEntityName!, { ...conditions, [foreignKeyInChild]: entity.id });
|
|
523
|
-
},
|
|
524
|
-
update: (id: number, data: any) => this.update(methodDef.childEntityName!, id, data),
|
|
525
|
-
upsert: (conditions: any = {}, data: any = {}) => this.upsert(methodDef.childEntityName!, { ...data, [foreignKeyInChild]: entity.id }, { ...conditions, [foreignKeyInChild]: entity.id }),
|
|
526
|
-
delete: (id?: number) => {
|
|
527
|
-
if (id) {
|
|
528
|
-
this.delete(methodDef.childEntityName!, id);
|
|
529
|
-
} else {
|
|
530
|
-
const relatedEntities = this.find(methodDef.childEntityName!, { [foreignKeyInChild]: entity.id });
|
|
531
|
-
relatedEntities.forEach(e => this.delete(methodDef.childEntityName!, e.id));
|
|
532
|
-
}
|
|
533
|
-
},
|
|
534
|
-
subscribe: (event: any, callback: any) => this.subscribe(event, methodDef.childEntityName!, callback),
|
|
535
|
-
unsubscribe: (event: any, callback: any) => this.unsubscribe(event, methodDef.childEntityName!, callback),
|
|
536
|
-
push: (data: any) => this.insert(methodDef.childEntityName!, { ...data, [foreignKeyInChild]: entity.id }),
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
|
-
} else {
|
|
540
|
-
const fetcher = methodDef.fetch(entity);
|
|
541
|
-
if (methodDef.type === 'one-to-many') {
|
|
542
|
-
augmentedEntity[methodDef.name] = fetcher;
|
|
543
|
-
} else {
|
|
544
|
-
augmentedEntity[methodDef.name] = fetcher;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const storableFieldNames = new Set(this.getStorableFields(this.schemas[entityName]!).map(f => f.name));
|
|
550
|
-
const proxyHandler: ProxyHandler<T> = {
|
|
551
|
-
set: (target: T, prop: string, value: any): boolean => {
|
|
552
|
-
if (storableFieldNames.has(prop) && target[prop] !== value) {
|
|
553
|
-
this.update(entityName, target.id, { [prop]: value });
|
|
554
|
-
}
|
|
555
|
-
target[prop] = value;
|
|
556
|
-
return true;
|
|
557
|
-
},
|
|
558
|
-
get: (target: T, prop: string, receiver: any): any => Reflect.get(target, prop, receiver),
|
|
559
|
-
};
|
|
560
|
-
|
|
561
|
-
return new Proxy(augmentedEntity, proxyHandler);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
private buildWhereClause(conditions: Record<string, any>, tablePrefix?: string): { clause: string; values: any[] } {
|
|
565
|
-
const whereParts: string[] = [];
|
|
566
|
-
const values: any[] = [];
|
|
567
|
-
|
|
568
|
-
for (const key in conditions) {
|
|
569
|
-
if (key.startsWith('$')) continue;
|
|
570
|
-
|
|
571
|
-
const value = conditions[key];
|
|
572
|
-
const fieldName = tablePrefix ? `${tablePrefix}.${key}` : key;
|
|
573
|
-
|
|
574
|
-
if (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
|
|
575
|
-
const operator = Object.keys(value)[0];
|
|
576
|
-
if (!operator || !operator.startsWith('$')) {
|
|
577
|
-
throw new Error(`Querying on nested object field '${key}' is not supported. Use simple values or query operators like $gt.`);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
const operand = value[operator];
|
|
581
|
-
let sqlOperator = '';
|
|
582
|
-
switch (operator) {
|
|
583
|
-
case '$gt': sqlOperator = '>'; break;
|
|
584
|
-
case '$gte': sqlOperator = '>='; break;
|
|
585
|
-
case '$lt': sqlOperator = '<'; break;
|
|
586
|
-
case '$lte': sqlOperator = '<='; break;
|
|
587
|
-
case '$ne': sqlOperator = '!='; break;
|
|
588
|
-
case '$in':
|
|
589
|
-
if (!Array.isArray(operand)) throw new Error(`$in operator for field '${key}' requires an array value.`);
|
|
590
|
-
if (operand.length === 0) {
|
|
591
|
-
whereParts.push('1 = 0');
|
|
592
|
-
} else {
|
|
593
|
-
const placeholders = operand.map(() => '?').join(', ');
|
|
594
|
-
whereParts.push(`${fieldName} IN (${placeholders})`);
|
|
595
|
-
values.push(...operand.map(v => this.transformForStorage({ v }).v));
|
|
596
|
-
}
|
|
597
|
-
continue;
|
|
598
|
-
default:
|
|
599
|
-
throw new Error(`Unsupported query operator: '${operator}' on field '${key}'.`);
|
|
600
|
-
}
|
|
601
|
-
whereParts.push(`${fieldName} ${sqlOperator} ?`);
|
|
602
|
-
values.push(this.transformForStorage({ operand }).operand);
|
|
603
|
-
} else {
|
|
604
|
-
whereParts.push(`${fieldName} = ?`);
|
|
605
|
-
values.push(this.transformForStorage({ value }).value);
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
return {
|
|
610
|
-
clause: whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '',
|
|
611
|
-
values
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
private buildJoinQuery(entityName: string, conditions: Record<string, any>, includeFields: string[]): {
|
|
616
|
-
sql: string;
|
|
617
|
-
values: any[];
|
|
618
|
-
joinedTables: { alias: string; entityName: string; relationship: Relationship }[];
|
|
619
|
-
} {
|
|
620
|
-
const { $limit, $offset, $sortBy, $include, ...whereConditions } = conditions;
|
|
621
|
-
|
|
622
|
-
let sql = `SELECT ${entityName}.*`;
|
|
623
|
-
const joinedTables: { alias: string; entityName: string; relationship: Relationship }[] = [];
|
|
624
|
-
const joinClauses: string[] = [];
|
|
625
|
-
|
|
626
|
-
for (const includeField of includeFields) {
|
|
627
|
-
const relationship = this.relationships.find(
|
|
628
|
-
rel => rel.from === entityName && rel.relationshipField === includeField && rel.type === 'belongs-to'
|
|
629
|
-
);
|
|
630
|
-
|
|
631
|
-
if (relationship) {
|
|
632
|
-
const alias = `${includeField}_tbl`;
|
|
633
|
-
joinedTables.push({ alias, entityName: relationship.to, relationship });
|
|
634
|
-
|
|
635
|
-
const joinedSchema = this.schemas[relationship.to]!;
|
|
636
|
-
const joinedFields = ['id', ...this.getStorableFields(joinedSchema).map(f => f.name)];
|
|
637
|
-
const aliasedColumns = joinedFields.map(field => `${alias}.${field} AS ${alias}_${field}`);
|
|
638
|
-
sql += `, ${aliasedColumns.join(', ')}`;
|
|
639
|
-
|
|
640
|
-
joinClauses.push(`LEFT JOIN ${relationship.to} ${alias} ON ${entityName}.${relationship.foreignKey} = ${alias}.id`);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
sql += ` FROM ${entityName}`;
|
|
645
|
-
if (joinClauses.length > 0) {
|
|
646
|
-
sql += ` ${joinClauses.join(' ')}`;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
const { clause: whereClause, values } = this.buildWhereClause(whereConditions, entityName);
|
|
650
|
-
if (whereClause) {
|
|
651
|
-
sql += ` ${whereClause}`;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
if ($sortBy) {
|
|
655
|
-
const [field, direction = 'ASC'] = ($sortBy as string).split(':');
|
|
656
|
-
sql += ` ORDER BY ${entityName}.${field} ${direction.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'}`;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
if ($limit) sql += ` LIMIT ${$limit}`;
|
|
660
|
-
if ($offset) sql += ` OFFSET ${$offset}`;
|
|
661
|
-
|
|
662
|
-
return {
|
|
663
|
-
sql,
|
|
664
|
-
values,
|
|
665
|
-
joinedTables
|
|
666
|
-
};
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
private parseJoinResults(rows: any[], entityName: string, joinedTables: { alias: string; entityName: string; relationship: Relationship }[]): {
|
|
670
|
-
entities: any[];
|
|
671
|
-
includedData: Record<string, any>[];
|
|
672
|
-
} {
|
|
673
|
-
const entities: any[] = [];
|
|
674
|
-
const includedDataArray: Record<string, any>[] = [];
|
|
675
|
-
|
|
676
|
-
for (const row of rows) {
|
|
677
|
-
const mainEntity: Record<string, any> = {};
|
|
678
|
-
const mainSchema = this.schemas[entityName]!;
|
|
679
|
-
const mainFields = ['id', ...this.getStorableFields(mainSchema).map(f => f.name)];
|
|
680
|
-
|
|
681
|
-
for (const field of mainFields) {
|
|
682
|
-
if (row[field] !== undefined) {
|
|
683
|
-
mainEntity[field] = row[field];
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
const includedData: Record<string, any> = {};
|
|
688
|
-
for (const { alias, entityName: joinedEntityName, relationship } of joinedTables) {
|
|
689
|
-
const joinedEntity: Record<string, any> = {};
|
|
690
|
-
const joinedSchema = this.schemas[joinedEntityName]!;
|
|
691
|
-
const joinedFields = ['id', ...this.getStorableFields(joinedSchema).map(f => f.name)];
|
|
692
|
-
|
|
693
|
-
let hasData = false;
|
|
694
|
-
for (const field of joinedFields) {
|
|
695
|
-
const aliasedFieldName = `${alias}_${field}`;
|
|
696
|
-
if (row[aliasedFieldName] !== undefined && row[aliasedFieldName] !== null) {
|
|
697
|
-
joinedEntity[field] = row[aliasedFieldName];
|
|
698
|
-
hasData = true;
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
if (hasData) {
|
|
703
|
-
const transformedJoinedEntity = this.transformFromStorage(joinedEntity, joinedSchema!);
|
|
704
|
-
const augmentedJoinedEntity = this._attachMethods(joinedEntityName, transformedJoinedEntity);
|
|
705
|
-
includedData[relationship.relationshipField] = augmentedJoinedEntity;
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
entities.push(this.transformFromStorage(mainEntity, mainSchema!));
|
|
710
|
-
includedDataArray.push(includedData);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
return { entities, includedData: includedDataArray };
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
private loadOneToManyIncludes(entityName: string, entities: any[], includeFields: string[]): Record<string, any>[] {
|
|
717
|
-
const includedDataArray: Record<string, any>[] = entities.map(() => ({}));
|
|
718
|
-
|
|
719
|
-
for (const includeField of includeFields) {
|
|
720
|
-
const relationship = this.relationships.find(
|
|
721
|
-
rel => rel.from === entityName && rel.relationshipField === includeField && rel.type === 'one-to-many'
|
|
722
|
-
);
|
|
723
|
-
|
|
724
|
-
if (relationship) {
|
|
725
|
-
const entityIds = entities.map(e => e.id);
|
|
726
|
-
if (entityIds.length === 0) continue;
|
|
727
|
-
|
|
728
|
-
const belongsToRel = this.relationships.find(r =>
|
|
729
|
-
r.type === 'belongs-to' &&
|
|
730
|
-
r.from === relationship.to &&
|
|
731
|
-
r.to === relationship.from
|
|
732
|
-
);
|
|
733
|
-
if (!belongsToRel) throw new Error(`No 'belongs-to' relationship found for one-to-many from ${relationship.from} to ${relationship.to}`);
|
|
734
|
-
const foreignKeyInChild = belongsToRel.foreignKey;
|
|
735
|
-
|
|
736
|
-
const placeholders = entityIds.map(() => '?').join(', ');
|
|
737
|
-
const sql = `SELECT * FROM ${relationship.to} WHERE ${foreignKeyInChild} IN (${placeholders})`;
|
|
738
|
-
const relatedRows = this.db.query(sql).all(...entityIds);
|
|
739
|
-
|
|
740
|
-
const relatedByParent: Record<string, any[]> = {};
|
|
741
|
-
for (const row of relatedRows) {
|
|
742
|
-
const parentId = row[foreignKeyInChild];
|
|
743
|
-
if (!relatedByParent[parentId]) {
|
|
744
|
-
relatedByParent[parentId] = [];
|
|
745
|
-
}
|
|
746
|
-
const transformedEntity = this.transformFromStorage(row, this.schemas[relationship.to]!);
|
|
747
|
-
const augmentedEntity = this._attachMethods(relationship.to, transformedEntity);
|
|
748
|
-
relatedByParent[parentId].push(augmentedEntity);
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
entities.forEach((entity, index) => {
|
|
752
|
-
includedDataArray[index][includeField] = relatedByParent[entity.id] || [];
|
|
753
|
-
});
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
return includedDataArray;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
public transaction<T>(callback: () => T): T {
|
|
761
|
-
try {
|
|
762
|
-
this.db.run('BEGIN TRANSACTION');
|
|
763
|
-
const result = callback();
|
|
764
|
-
this.db.run('COMMIT');
|
|
765
|
-
return result;
|
|
766
|
-
} catch (error) {
|
|
767
|
-
this.db.run('ROLLBACK');
|
|
768
|
-
throw new Error(`Transaction failed: ${(error as Error).message}`);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
private preprocessRelationshipFields(schema: z.ZodType<any>, data: Record<string, any>): Record<string, any> {
|
|
773
|
-
const processedData = { ...data };
|
|
774
|
-
for (const [key, value] of Object.entries(data)) {
|
|
775
|
-
if (this.isRelationshipField(schema, key)) {
|
|
776
|
-
if (value && typeof value === 'object' && 'id' in value) {
|
|
777
|
-
const foreignKey = `${key}Id`;
|
|
778
|
-
processedData[foreignKey] = value.id;
|
|
779
|
-
delete processedData[key];
|
|
780
|
-
} else if (typeof value === 'string') {
|
|
781
|
-
const foreignKey = `${key}Id`;
|
|
782
|
-
processedData[foreignKey] = value;
|
|
783
|
-
delete processedData[key];
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
return processedData;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
private insert<T extends Record<string, any>>(entityName: string, data: Omit<T, 'id'>): AugmentedEntity<any> {
|
|
791
|
-
const schema = this.schemas[entityName];
|
|
792
|
-
const processedData = this.preprocessRelationshipFields(schema!, data);
|
|
793
|
-
const validatedData = asZodObject(schema!).passthrough().parse(processedData);
|
|
794
|
-
const storableData = Object.fromEntries(
|
|
795
|
-
Object.entries(validatedData).filter(([key]) => !this.isRelationshipField(schema!, key))
|
|
796
|
-
);
|
|
797
|
-
const transformedData = this.transformForStorage(storableData);
|
|
798
|
-
const columns = Object.keys(transformedData);
|
|
799
|
-
let sql: string;
|
|
800
|
-
if (columns.length === 0) {
|
|
801
|
-
sql = `INSERT INTO ${entityName} DEFAULT VALUES`;
|
|
802
|
-
} else {
|
|
803
|
-
const placeholders = columns.map(() => '?').join(', ');
|
|
804
|
-
sql = `INSERT INTO ${entityName} (${columns.join(', ')}) VALUES (${placeholders})`;
|
|
805
|
-
}
|
|
806
|
-
const result = this.db.query(sql).run(...Object.values(transformedData));
|
|
807
|
-
const newEntity = this.get(entityName, result.lastInsertRowid as number);
|
|
808
|
-
if (!newEntity) throw new Error('Failed to retrieve entity after insertion');
|
|
809
|
-
this.emit('insert', entityName, newEntity);
|
|
810
|
-
if (this.subscriptions.insert[entityName]) {
|
|
811
|
-
this.subscriptions.insert[entityName].forEach(cb => cb(newEntity));
|
|
812
|
-
}
|
|
813
|
-
return newEntity;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
private get<T extends Record<string, any>>(entityName: string, conditions: number | Partial<T>): AugmentedEntity<any> | null {
|
|
817
|
-
const queryConditions = typeof conditions === 'number' ? { id: conditions } : conditions;
|
|
818
|
-
if (Object.keys(queryConditions).length === 0) return null;
|
|
819
|
-
const results = this.find(entityName, { ...queryConditions, $limit: 1 });
|
|
820
|
-
return results.length > 0 ? results[0] : null;
|
|
821
|
-
}
|
|
822
|
-
private findMany<T extends Record<string, any>>(entityName: string, options: {
|
|
823
|
-
where?: Record<string, any>;
|
|
824
|
-
orderBy?: Record<string, 'asc' | 'desc'>;
|
|
825
|
-
take?: number;
|
|
826
|
-
}): AugmentedEntity<any>[] {
|
|
827
|
-
const { where = {}, orderBy, take } = options;
|
|
828
|
-
|
|
829
|
-
// Convert Prisma-style options to internal format
|
|
830
|
-
const conditions: Record<string, any> = { ...where };
|
|
831
|
-
|
|
832
|
-
if (orderBy) {
|
|
833
|
-
const field = Object.keys(orderBy)[0];
|
|
834
|
-
const direction = orderBy[field];
|
|
835
|
-
conditions.$sortBy = `${field}:${direction}`;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
if (take) conditions.$limit = take;
|
|
839
|
-
|
|
840
|
-
return this.find(entityName, conditions);
|
|
841
|
-
}
|
|
842
|
-
private findUnique<T extends Record<string, any>>(entityName: string, options: {
|
|
843
|
-
where: Record<string, any>;
|
|
844
|
-
}): AugmentedEntity<any> | null {
|
|
845
|
-
const { where } = options;
|
|
846
|
-
|
|
847
|
-
// Convert to internal format with limit 1
|
|
848
|
-
const conditions: Record<string, any> = {
|
|
849
|
-
...where,
|
|
850
|
-
$limit: 1
|
|
851
|
-
};
|
|
852
|
-
|
|
853
|
-
const results = this.find(entityName, conditions);
|
|
854
|
-
return results.length > 0 ? results[0] : null;
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
private findOne<T extends Record<string, any>>(entityName: string, conditions: Record<string, any>): AugmentedEntity<any> | null {
|
|
860
|
-
const results = this.find(entityName, { ...conditions, $limit: 1 });
|
|
861
|
-
return results.length > 0 ? results[0] : null;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
private find<T extends Record<string, any>>(entityName: string, conditions: Record<string, any> = {}): AugmentedEntity<any>[] {
|
|
865
|
-
const { $include, ...otherConditions } = conditions;
|
|
866
|
-
|
|
867
|
-
const includeFields: string[] = [];
|
|
868
|
-
if ($include) {
|
|
869
|
-
if (typeof $include === 'string') {
|
|
870
|
-
includeFields.push($include);
|
|
871
|
-
} else if (Array.isArray($include)) {
|
|
872
|
-
includeFields.push(...$include);
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
if (includeFields.length === 0) {
|
|
877
|
-
const { $limit, $offset, $sortBy, ...whereConditions } = otherConditions;
|
|
878
|
-
const { clause: whereClause, values: whereValues } = this.buildWhereClause(whereConditions);
|
|
879
|
-
let orderByClause = '';
|
|
880
|
-
if ($sortBy) {
|
|
881
|
-
const [field, direction = 'ASC'] = ($sortBy as string).split(':');
|
|
882
|
-
orderByClause = `ORDER BY ${field} ${direction.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'}`;
|
|
883
|
-
}
|
|
884
|
-
const limitClause = $limit ? `LIMIT ${$limit}` : '';
|
|
885
|
-
const offsetClause = $offset ? `OFFSET ${$offset}` : '';
|
|
886
|
-
const sql = `SELECT * FROM ${entityName} ${whereClause} ${orderByClause} ${limitClause} ${offsetClause}`;
|
|
887
|
-
const rows = this.db.query(sql).all(...whereValues);
|
|
888
|
-
|
|
889
|
-
return rows.map(row => {
|
|
890
|
-
const entity = this.transformFromStorage(row as any, this.schemas[entityName]!) as T;
|
|
891
|
-
return this._attachMethods(entityName, entity);
|
|
892
|
-
});
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
const belongsToIncludes = includeFields.filter(field => {
|
|
896
|
-
const rel = this.relationships.find(r => r.from === entityName && r.relationshipField === field);
|
|
897
|
-
return rel?.type === 'belongs-to';
|
|
898
|
-
});
|
|
899
|
-
|
|
900
|
-
const oneToManyIncludes = includeFields.filter(field => {
|
|
901
|
-
const rel = this.relationships.find(r => r.from === entityName && r.relationshipField === field);
|
|
902
|
-
return rel?.type === 'one-to-many';
|
|
903
|
-
});
|
|
904
|
-
|
|
905
|
-
let entities: any[];
|
|
906
|
-
let includedDataArray: Record<string, any>[];
|
|
907
|
-
|
|
908
|
-
if (belongsToIncludes.length > 0) {
|
|
909
|
-
const { sql, values, joinedTables } = this.buildJoinQuery(entityName, otherConditions, belongsToIncludes);
|
|
910
|
-
// JOIN query for belongs-to includes
|
|
911
|
-
const rows = this.db.query(sql).all(...values);
|
|
912
|
-
const result = this.parseJoinResults(rows, entityName, joinedTables);
|
|
913
|
-
entities = result.entities;
|
|
914
|
-
includedDataArray = result.includedData;
|
|
915
|
-
} else {
|
|
916
|
-
const { $limit, $offset, $sortBy, ...whereConditions } = otherConditions;
|
|
917
|
-
const { clause: whereClause, values: whereValues } = this.buildWhereClause(whereConditions);
|
|
918
|
-
let orderByClause = '';
|
|
919
|
-
if ($sortBy) {
|
|
920
|
-
const [field, direction = 'ASC'] = ($sortBy as string).split(':');
|
|
921
|
-
orderByClause = `ORDER BY ${field} ${direction.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'}`;
|
|
922
|
-
}
|
|
923
|
-
const limitClause = $limit ? `LIMIT ${$limit}` : '';
|
|
924
|
-
const offsetClause = $offset ? `OFFSET ${$offset}` : '';
|
|
925
|
-
const sql = `SELECT * FROM ${entityName} ${whereClause} ${orderByClause} ${limitClause} ${offsetClause}`;
|
|
926
|
-
const rows = this.db.query(sql).all(...whereValues);
|
|
927
|
-
entities = rows.map(row => this.transformFromStorage(row as any, this.schemas[entityName]!) as T);
|
|
928
|
-
includedDataArray = entities.map(() => ({}));
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
if (oneToManyIncludes.length > 0) {
|
|
932
|
-
// Batch query for one-to-many includes
|
|
933
|
-
const oneToManyData = this.loadOneToManyIncludes(entityName, entities, oneToManyIncludes);
|
|
934
|
-
includedDataArray = includedDataArray.map((includedData, index) => ({
|
|
935
|
-
...includedData,
|
|
936
|
-
...oneToManyData[index]
|
|
937
|
-
}));
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
return entities.map((entity, index) => {
|
|
941
|
-
return this._attachMethods(entityName, entity, includedDataArray[index]);
|
|
942
|
-
});
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
private update<T extends Record<string, any>>(entityName: string, id: number, data: Partial<Omit<T, 'id'>>): AugmentedEntity<any> | null {
|
|
946
|
-
const schema = this.schemas[entityName];
|
|
947
|
-
const validatedData = asZodObject(schema!).partial().parse(data);
|
|
948
|
-
const transformedData = this.transformForStorage(validatedData);
|
|
949
|
-
if (Object.keys(transformedData).length === 0) return this.get(entityName, { id } as unknown as Partial<T>);
|
|
950
|
-
const setClause = Object.keys(transformedData).map(key => `${key} = ?`).join(', ');
|
|
951
|
-
const values = [...Object.values(transformedData), id];
|
|
952
|
-
const sql = `UPDATE ${entityName} SET ${setClause} WHERE id = ?`;
|
|
953
|
-
this.db.query(sql).run(...values);
|
|
954
|
-
const updatedEntity = this.get(entityName, { id } as unknown as Partial<T>);
|
|
955
|
-
if (updatedEntity) {
|
|
956
|
-
this.emit('update', entityName, updatedEntity);
|
|
957
|
-
if (this.subscriptions.update[entityName]) {
|
|
958
|
-
this.subscriptions.update[entityName].forEach(cb => cb(updatedEntity));
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
return updatedEntity;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
/**
|
|
965
|
-
* Update with either a numeric ID or a filter object.
|
|
966
|
-
* If a number is passed, it's treated as the ID.
|
|
967
|
-
* If an object is passed, it's used as filter conditions to find the row to update.
|
|
968
|
-
*/
|
|
969
|
-
private updateWithFilter<T extends Record<string, any>>(
|
|
970
|
-
entityName: string,
|
|
971
|
-
idOrFilter: number | Partial<T>,
|
|
972
|
-
data: Partial<Omit<T, 'id'>>
|
|
973
|
-
): AugmentedEntity<any> | null {
|
|
974
|
-
// If it's a number, use the existing update method
|
|
975
|
-
if (typeof idOrFilter === 'number') {
|
|
976
|
-
return this.update(entityName, idOrFilter, data);
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
// Otherwise, treat it as a filter object - find the entity first
|
|
980
|
-
const entity = this.findOne(entityName, idOrFilter);
|
|
981
|
-
if (!entity) {
|
|
982
|
-
return null;
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
// Update using the found entity's ID
|
|
986
|
-
return this.update(entityName, entity.id, data);
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
/**
|
|
990
|
-
* Execute UPDATE ... SET ... WHERE in a single SQL query.
|
|
991
|
-
* Returns the number of rows affected.
|
|
992
|
-
*/
|
|
993
|
-
private _updateWhere(entityName: string, data: Record<string, any>, conditions: Record<string, any>): number {
|
|
994
|
-
const schema = this.schemas[entityName];
|
|
995
|
-
const validatedData = asZodObject(schema!).partial().parse(data);
|
|
996
|
-
const transformedData = this.transformForStorage(validatedData);
|
|
997
|
-
if (Object.keys(transformedData).length === 0) return 0;
|
|
998
|
-
|
|
999
|
-
const { clause: whereClause, values: whereValues } = this.buildWhereClause(conditions);
|
|
1000
|
-
if (!whereClause) throw new Error('update().where() requires at least one condition');
|
|
1001
|
-
|
|
1002
|
-
const setCols = Object.keys(transformedData);
|
|
1003
|
-
const setClause = setCols.map(key => `${key} = ?`).join(', ');
|
|
1004
|
-
const setValues = setCols.map(key => transformedData[key]);
|
|
1005
|
-
|
|
1006
|
-
const sql = `UPDATE ${entityName} SET ${setClause} ${whereClause}`;
|
|
1007
|
-
const result = this.db.query(sql).run(...setValues, ...whereValues);
|
|
1008
|
-
|
|
1009
|
-
// Fire events for affected rows
|
|
1010
|
-
const affected = (result as any).changes ?? 0;
|
|
1011
|
-
if (affected > 0 && (this.subscriptions.update[entityName]?.length || this.options.changeTracking)) {
|
|
1012
|
-
const updated = this.find(entityName, conditions);
|
|
1013
|
-
for (const entity of updated) {
|
|
1014
|
-
this.emit('update', entityName, entity);
|
|
1015
|
-
if (this.subscriptions.update[entityName]) {
|
|
1016
|
-
this.subscriptions.update[entityName].forEach(cb => cb(entity));
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
return affected;
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
/**
|
|
1024
|
-
* Create a fluent UpdateBuilder: `update(data).where(filter).exec()`
|
|
1025
|
-
*/
|
|
1026
|
-
private _createUpdateBuilder(entityName: string, data: Record<string, any>): UpdateBuilder<any> {
|
|
1027
|
-
let _conditions: Record<string, any> = {};
|
|
1028
|
-
|
|
1029
|
-
const builder: UpdateBuilder<any> = {
|
|
1030
|
-
where: (conditions: Record<string, any>) => {
|
|
1031
|
-
_conditions = { ..._conditions, ...conditions };
|
|
1032
|
-
return builder;
|
|
1033
|
-
},
|
|
1034
|
-
exec: () => {
|
|
1035
|
-
return this._updateWhere(entityName, data, _conditions);
|
|
1036
|
-
},
|
|
1037
|
-
};
|
|
1038
|
-
return builder;
|
|
1039
|
-
}
|
|
1040
|
-
private upsert<T extends Record<string, any>>(entityName: string, data: Omit<T, 'id'> & { id?: string }, conditions: Partial<T> = {}): AugmentedEntity<any> {
|
|
1041
|
-
const schema = this.schemas[entityName];
|
|
1042
|
-
const processedData = this.preprocessRelationshipFields(schema!, data);
|
|
1043
|
-
const processedConditions = this.preprocessRelationshipFields(schema!, conditions);
|
|
1044
|
-
const hasId = processedData.id && typeof processedData.id === 'number';
|
|
1045
|
-
const existing = hasId ? this.get(entityName, { id: processedData.id } as Partial<T>) : Object.keys(processedConditions).length > 0 ? this.get(entityName, processedConditions) : null;
|
|
1046
|
-
if (existing) {
|
|
1047
|
-
const updateData = { ...processedData };
|
|
1048
|
-
delete updateData.id;
|
|
1049
|
-
return this.update(entityName, existing.id, updateData) as AugmentedEntity<any>;
|
|
1050
|
-
} else {
|
|
1051
|
-
const insertData = { ...processedConditions, ...processedData };
|
|
1052
|
-
delete insertData.id;
|
|
1053
|
-
return this.insert(entityName, insertData);
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
private delete(entityName: string, id: number): void {
|
|
1058
|
-
const entity = this.get(entityName, { id });
|
|
1059
|
-
if (entity) {
|
|
1060
|
-
const sql = `DELETE FROM ${entityName} WHERE id = ?`;
|
|
1061
|
-
this.db.query(sql).run(id);
|
|
1062
|
-
this.emit('delete', entityName, entity);
|
|
1063
|
-
if (this.subscriptions.delete[entityName]) {
|
|
1064
|
-
this.subscriptions.delete[entityName].forEach(cb => cb(entity));
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
private subscribe(event: 'insert' | 'update' | 'delete', entityName: string, callback: (data: any) => void): void {
|
|
1070
|
-
this.subscriptions[event][entityName] = this.subscriptions[event][entityName] || [];
|
|
1071
|
-
this.subscriptions[event][entityName].push(callback);
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
private unsubscribe(event: 'insert' | 'update' | 'delete', entityName: string, callback: (data: any) => void): void {
|
|
1075
|
-
if (this.subscriptions[event][entityName]) {
|
|
1076
|
-
this.subscriptions[event][entityName] = this.subscriptions[event][entityName].filter(cb => cb !== callback);
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
// ================== Fluent Query Builder API ==================
|
|
1081
|
-
|
|
1082
|
-
/**
|
|
1083
|
-
* Creates a QueryBuilder instance bound to a specific table.
|
|
1084
|
-
* The executor callbacks wire the builder to the real DB and schema parsing.
|
|
1085
|
-
*/
|
|
1086
|
-
private _createQueryBuilder(entityName: string, initialCols: string[]): QueryBuilder<any> {
|
|
1087
|
-
const schema = this.schemas[entityName]!;
|
|
1088
|
-
|
|
1089
|
-
const executor = (sql: string, params: any[], raw: boolean): any[] => {
|
|
1090
|
-
const rows = this.db.query(sql).all(...params);
|
|
1091
|
-
if (raw) return rows;
|
|
1092
|
-
return rows.map((row: any) => {
|
|
1093
|
-
const entity = this.transformFromStorage(row as any, schema);
|
|
1094
|
-
return this._attachMethods(entityName, entity);
|
|
1095
|
-
});
|
|
1096
|
-
};
|
|
1097
|
-
|
|
1098
|
-
const singleExecutor = (sql: string, params: any[], raw: boolean): any | null => {
|
|
1099
|
-
const results = executor(sql, params, raw);
|
|
1100
|
-
return results.length > 0 ? results[0] : null;
|
|
1101
|
-
};
|
|
1102
|
-
|
|
1103
|
-
const builder = new QueryBuilder(entityName, executor, singleExecutor);
|
|
1104
|
-
if (initialCols.length > 0) {
|
|
1105
|
-
builder.select(...initialCols);
|
|
1106
|
-
}
|
|
1107
|
-
return builder;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
// ================== Proxy Callback Query API ==================
|
|
1111
|
-
|
|
1112
|
-
/**
|
|
1113
|
-
* Execute a complex query using the Proxy Callback pattern ("Midnight" style).
|
|
1114
|
-
*
|
|
1115
|
-
* ```ts
|
|
1116
|
-
* const results = db.query(c => {
|
|
1117
|
-
* const { users: u, posts: p } = c;
|
|
1118
|
-
* return {
|
|
1119
|
-
* select: { ...u, postTitle: p.title },
|
|
1120
|
-
* join: [u.id, p.authorId],
|
|
1121
|
-
* where: { [u.name]: 'Alice' },
|
|
1122
|
-
* };
|
|
1123
|
-
* });
|
|
1124
|
-
* ```
|
|
1125
|
-
*/
|
|
1126
|
-
public query<T extends Record<string, any> = Record<string, any>>(
|
|
1127
|
-
callback: (ctx: { [K in keyof Schemas]: Record<string, any> }) => ProxyQueryResult
|
|
1128
|
-
): T[] {
|
|
1129
|
-
return executeProxyQuery(
|
|
1130
|
-
this.schemas,
|
|
1131
|
-
callback as any,
|
|
1132
|
-
(sql: string, params: any[]) => {
|
|
1133
|
-
return this.db.query(sql).all(...params) as T[];
|
|
1134
|
-
},
|
|
1135
|
-
);
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
// Re-export the class with proper typing so `new SatiDB(...)` returns entity accessors
|
|
1140
|
-
const SatiDB = _SatiDB as unknown as new <S extends SchemaMap>(dbFile: string, schemas: S, options?: SatiDBOptions) => _SatiDB<S> & TypedAccessors<S>;
|
|
1141
|
-
type SatiDB<S extends SchemaMap> = _SatiDB<S> & TypedAccessors<S>;
|
|
1142
|
-
|
|
1143
|
-
export type DB<S extends SchemaMap> = SatiDB<S>;
|
|
1144
|
-
export type { SatiDBOptions };
|
|
1145
|
-
export { SatiDB, z };
|
|
1146
|
-
export { QueryBuilder } from './query-builder';
|
|
1147
|
-
export { ColumnNode, type ProxyQueryResult } from './proxy-query';
|
|
1148
|
-
export {
|
|
1149
|
-
type ASTNode, type WhereCallback, type SetCallback,
|
|
1150
|
-
type TypedColumnProxy, type FunctionProxy, type Operators,
|
|
1151
|
-
compileAST, wrapNode, createColumnProxy, createFunctionProxy, op,
|
|
1152
|
-
} from './ast';
|
|
1153
|
-
|