sqlite-zod-orm 3.8.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -93
- package/dist/index.js +2437 -2386
- package/package.json +5 -3
- package/src/ast.ts +1 -1
- package/src/builder.ts +311 -0
- package/src/context.ts +25 -0
- package/src/crud.ts +163 -0
- package/src/database.ts +173 -396
- package/src/entity.ts +62 -0
- package/src/helpers.ts +87 -0
- package/src/index.ts +2 -3
- package/src/iqo.ts +172 -0
- package/src/{proxy-query.ts → proxy.ts} +22 -58
- package/src/query.ts +136 -0
- package/src/types.ts +27 -6
- package/dist/satidb.js +0 -26
- package/src/build.ts +0 -21
- package/src/query-builder.ts +0 -669
package/src/database.ts
CHANGED
|
@@ -1,50 +1,86 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* database.ts — Main Database class for sqlite-zod-orm
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Slim orchestrator: initializes the schema, creates tables/triggers,
|
|
5
|
+
* and delegates CRUD, entity augmentation, and query building to
|
|
6
|
+
* focused modules.
|
|
6
7
|
*/
|
|
7
8
|
import { Database as SqliteDatabase } from 'bun:sqlite';
|
|
8
9
|
import { z } from 'zod';
|
|
9
|
-
import { QueryBuilder } from './query
|
|
10
|
-
import { executeProxyQuery, type ProxyQueryResult } from './proxy-query';
|
|
10
|
+
import { QueryBuilder, executeProxyQuery, createQueryBuilder, type ProxyQueryResult } from './query';
|
|
11
11
|
import type {
|
|
12
12
|
SchemaMap, DatabaseOptions, Relationship, RelationsConfig,
|
|
13
13
|
EntityAccessor, TypedAccessors, TypedNavAccessors, AugmentedEntity, UpdateBuilder,
|
|
14
|
-
ProxyColumns, InferSchema,
|
|
14
|
+
ProxyColumns, InferSchema, ChangeEvent,
|
|
15
15
|
} from './types';
|
|
16
16
|
import { asZodObject } from './types';
|
|
17
17
|
import {
|
|
18
18
|
parseRelationsConfig,
|
|
19
19
|
getStorableFields,
|
|
20
|
-
zodTypeToSqlType,
|
|
20
|
+
zodTypeToSqlType,
|
|
21
21
|
} from './schema';
|
|
22
|
+
import { transformFromStorage } from './schema';
|
|
23
|
+
import type { DatabaseContext } from './context';
|
|
24
|
+
import { buildWhereClause } from './helpers';
|
|
25
|
+
import { attachMethods } from './entity';
|
|
26
|
+
import {
|
|
27
|
+
insert, insertMany, update, upsert, deleteEntity, createDeleteBuilder,
|
|
28
|
+
getById, getOne, findMany, updateWhere, createUpdateBuilder,
|
|
29
|
+
} from './crud';
|
|
22
30
|
|
|
23
31
|
// =============================================================================
|
|
24
32
|
// Database Class
|
|
25
33
|
// =============================================================================
|
|
26
34
|
|
|
35
|
+
type Listener = {
|
|
36
|
+
table: string;
|
|
37
|
+
event: ChangeEvent;
|
|
38
|
+
callback: (row: any) => void | Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
|
|
27
41
|
class _Database<Schemas extends SchemaMap> {
|
|
28
42
|
private db: SqliteDatabase;
|
|
43
|
+
private _reactive: boolean;
|
|
29
44
|
private schemas: Schemas;
|
|
30
45
|
private relationships: Relationship[];
|
|
31
46
|
private options: DatabaseOptions;
|
|
32
|
-
private pollInterval: number;
|
|
33
47
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
/** Shared context for extracted modules. */
|
|
49
|
+
private _ctx: DatabaseContext;
|
|
50
|
+
|
|
51
|
+
/** Registered change listeners. */
|
|
52
|
+
private _listeners: Listener[] = [];
|
|
53
|
+
|
|
54
|
+
/** Watermark: last processed change id from _changes table. */
|
|
55
|
+
private _changeWatermark: number = 0;
|
|
56
|
+
|
|
57
|
+
/** Global poll timer (single loop for all listeners). */
|
|
58
|
+
private _pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
59
|
+
|
|
60
|
+
/** Poll interval in ms. */
|
|
61
|
+
private _pollInterval: number;
|
|
37
62
|
|
|
38
63
|
constructor(dbFile: string, schemas: Schemas, options: DatabaseOptions = {}) {
|
|
39
64
|
this.db = new SqliteDatabase(dbFile);
|
|
40
|
-
this.db.run('PRAGMA journal_mode = WAL');
|
|
65
|
+
this.db.run('PRAGMA journal_mode = WAL');
|
|
41
66
|
this.db.run('PRAGMA foreign_keys = ON');
|
|
42
67
|
this.schemas = schemas;
|
|
43
68
|
this.options = options;
|
|
44
|
-
this.
|
|
69
|
+
this._reactive = options.reactive !== false; // default true
|
|
70
|
+
this._pollInterval = options.pollInterval ?? 100;
|
|
45
71
|
this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
|
|
72
|
+
|
|
73
|
+
// Build the context that extracted modules use
|
|
74
|
+
this._ctx = {
|
|
75
|
+
db: this.db,
|
|
76
|
+
schemas: this.schemas as SchemaMap,
|
|
77
|
+
relationships: this.relationships,
|
|
78
|
+
attachMethods: (name, entity) => attachMethods(this._ctx, name, entity),
|
|
79
|
+
buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix),
|
|
80
|
+
};
|
|
81
|
+
|
|
46
82
|
this.initializeTables();
|
|
47
|
-
this.initializeChangeTracking();
|
|
83
|
+
if (this._reactive) this.initializeChangeTracking();
|
|
48
84
|
this.runMigrations();
|
|
49
85
|
if (options.indexes) this.createIndexes(options.indexes);
|
|
50
86
|
|
|
@@ -52,84 +88,103 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
52
88
|
for (const entityName of Object.keys(schemas)) {
|
|
53
89
|
const key = entityName as keyof Schemas;
|
|
54
90
|
const accessor: EntityAccessor<Schemas[typeof key]> = {
|
|
55
|
-
insert: (data) => this.
|
|
91
|
+
insert: (data) => insert(this._ctx, entityName, data),
|
|
92
|
+
insertMany: (rows: any[]) => insertMany(this._ctx, entityName, rows),
|
|
56
93
|
update: (idOrData: any, data?: any) => {
|
|
57
|
-
if (typeof idOrData === 'number') return this.
|
|
58
|
-
return this.
|
|
94
|
+
if (typeof idOrData === 'number') return update(this._ctx, entityName, idOrData, data);
|
|
95
|
+
return createUpdateBuilder(this._ctx, entityName, idOrData);
|
|
96
|
+
},
|
|
97
|
+
upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
|
|
98
|
+
delete: ((id?: any) => {
|
|
99
|
+
if (typeof id === 'number') return deleteEntity(this._ctx, entityName, id);
|
|
100
|
+
return createDeleteBuilder(this._ctx, entityName);
|
|
101
|
+
}) as any,
|
|
102
|
+
select: (...cols: string[]) => createQueryBuilder(this._ctx, entityName, cols),
|
|
103
|
+
on: (event: ChangeEvent, callback: (row: any) => void | Promise<void>) => {
|
|
104
|
+
return this._registerListener(entityName, event, callback);
|
|
59
105
|
},
|
|
60
|
-
upsert: (conditions, data) => this.upsert(entityName, data, conditions),
|
|
61
|
-
delete: (id) => this.delete(entityName, id),
|
|
62
|
-
select: (...cols: string[]) => this._createQueryBuilder(entityName, cols),
|
|
63
106
|
_tableName: entityName,
|
|
64
107
|
};
|
|
65
108
|
(this as any)[key] = accessor;
|
|
66
109
|
}
|
|
67
110
|
}
|
|
68
111
|
|
|
69
|
-
//
|
|
112
|
+
// =========================================================================
|
|
70
113
|
// Table Initialization & Migrations
|
|
71
|
-
//
|
|
114
|
+
// =========================================================================
|
|
72
115
|
|
|
73
116
|
private initializeTables(): void {
|
|
74
117
|
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
75
118
|
const storableFields = getStorableFields(schema);
|
|
76
|
-
const columnDefs = storableFields.map(f =>
|
|
119
|
+
const columnDefs = storableFields.map(f => `"${f.name}" ${zodTypeToSqlType(f.type)}`);
|
|
77
120
|
const constraints: string[] = [];
|
|
78
121
|
|
|
79
|
-
// Add FOREIGN KEY constraints for FK columns declared in the schema
|
|
80
122
|
const belongsToRels = this.relationships.filter(
|
|
81
123
|
rel => rel.type === 'belongs-to' && rel.from === entityName
|
|
82
124
|
);
|
|
83
125
|
for (const rel of belongsToRels) {
|
|
84
|
-
constraints.push(`FOREIGN KEY (${rel.foreignKey}) REFERENCES ${rel.to}(id) ON DELETE SET NULL`);
|
|
126
|
+
constraints.push(`FOREIGN KEY ("${rel.foreignKey}") REFERENCES "${rel.to}"(id) ON DELETE SET NULL`);
|
|
85
127
|
}
|
|
86
128
|
|
|
87
129
|
const allCols = columnDefs.join(', ');
|
|
88
130
|
const allConstraints = constraints.length > 0 ? ', ' + constraints.join(', ') : '';
|
|
89
|
-
this.db.run(`CREATE TABLE IF NOT EXISTS ${entityName} (id INTEGER PRIMARY KEY AUTOINCREMENT, ${allCols}${allConstraints})`);
|
|
131
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS "${entityName}" (id INTEGER PRIMARY KEY AUTOINCREMENT, ${allCols}${allConstraints})`);
|
|
90
132
|
}
|
|
91
133
|
}
|
|
92
134
|
|
|
93
135
|
/**
|
|
94
136
|
* Initialize per-table change tracking using triggers.
|
|
95
137
|
*
|
|
96
|
-
* Creates a `
|
|
97
|
-
*
|
|
98
|
-
*
|
|
138
|
+
* Creates a `_changes` table that logs every insert/update/delete with
|
|
139
|
+
* the table name, operation, and affected row id. This enables
|
|
140
|
+
* row-level change detection for the `on()` API.
|
|
99
141
|
*/
|
|
100
142
|
private initializeChangeTracking(): void {
|
|
101
|
-
this.db.run(`CREATE TABLE IF NOT EXISTS
|
|
102
|
-
|
|
103
|
-
|
|
143
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS "_changes" (
|
|
144
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
145
|
+
tbl TEXT NOT NULL,
|
|
146
|
+
op TEXT NOT NULL,
|
|
147
|
+
row_id INTEGER NOT NULL
|
|
104
148
|
)`);
|
|
105
149
|
|
|
106
150
|
for (const entityName of Object.keys(this.schemas)) {
|
|
107
|
-
//
|
|
108
|
-
this.db.run(`
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
151
|
+
// INSERT trigger — logs NEW.id
|
|
152
|
+
this.db.run(`CREATE TRIGGER IF NOT EXISTS "_trg_${entityName}_insert"
|
|
153
|
+
AFTER INSERT ON "${entityName}"
|
|
154
|
+
BEGIN
|
|
155
|
+
INSERT INTO "_changes" (tbl, op, row_id) VALUES ('${entityName}', 'insert', NEW.id);
|
|
156
|
+
END`);
|
|
157
|
+
|
|
158
|
+
// UPDATE trigger — logs NEW.id (post-update row)
|
|
159
|
+
this.db.run(`CREATE TRIGGER IF NOT EXISTS "_trg_${entityName}_update"
|
|
160
|
+
AFTER UPDATE ON "${entityName}"
|
|
161
|
+
BEGIN
|
|
162
|
+
INSERT INTO "_changes" (tbl, op, row_id) VALUES ('${entityName}', 'update', NEW.id);
|
|
163
|
+
END`);
|
|
164
|
+
|
|
165
|
+
// DELETE trigger — logs OLD.id (row that was deleted)
|
|
166
|
+
this.db.run(`CREATE TRIGGER IF NOT EXISTS "_trg_${entityName}_delete"
|
|
167
|
+
AFTER DELETE ON "${entityName}"
|
|
168
|
+
BEGIN
|
|
169
|
+
INSERT INTO "_changes" (tbl, op, row_id) VALUES ('${entityName}', 'delete', OLD.id);
|
|
170
|
+
END`);
|
|
120
171
|
}
|
|
172
|
+
|
|
173
|
+
// Initialize watermark to current max (skip replaying historical changes)
|
|
174
|
+
const row = this.db.query('SELECT MAX(id) as maxId FROM "_changes"').get() as any;
|
|
175
|
+
this._changeWatermark = row?.maxId ?? 0;
|
|
121
176
|
}
|
|
122
177
|
|
|
123
178
|
private runMigrations(): void {
|
|
124
179
|
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
125
|
-
const existingColumns = this.db.query(`PRAGMA table_info(${entityName})`).all() as any[];
|
|
180
|
+
const existingColumns = this.db.query(`PRAGMA table_info("${entityName}")`).all() as any[];
|
|
126
181
|
const existingNames = new Set(existingColumns.map(c => c.name));
|
|
127
182
|
|
|
128
183
|
const storableFields = getStorableFields(schema);
|
|
129
184
|
for (const field of storableFields) {
|
|
130
185
|
if (!existingNames.has(field.name)) {
|
|
131
186
|
const sqlType = zodTypeToSqlType(field.type);
|
|
132
|
-
this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${sqlType}`);
|
|
187
|
+
this.db.run(`ALTER TABLE "${entityName}" ADD COLUMN "${field.name}" ${sqlType}`);
|
|
133
188
|
}
|
|
134
189
|
}
|
|
135
190
|
}
|
|
@@ -140,388 +195,110 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
140
195
|
for (const def of indexDefs) {
|
|
141
196
|
const cols = Array.isArray(def) ? def : [def];
|
|
142
197
|
const idxName = `idx_${tableName}_${cols.join('_')}`;
|
|
143
|
-
this.db.run(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${tableName} (${cols.join(', ')})`);
|
|
198
|
+
this.db.run(`CREATE INDEX IF NOT EXISTS "${idxName}" ON "${tableName}" (${cols.map(c => `"${c}"`).join(', ')})`);
|
|
144
199
|
}
|
|
145
200
|
}
|
|
146
201
|
}
|
|
147
202
|
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
/** Bump the in-memory revision counter. Called by our CRUD methods (same-process fast path). */
|
|
153
|
-
private _bumpRevision(entityName: string): void {
|
|
154
|
-
this._revisions[entityName] = (this._revisions[entityName] ?? 0) + 1;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Get the change sequence for a table.
|
|
159
|
-
*
|
|
160
|
-
* Reads from `_satidb_changes` — a per-table seq counter bumped by triggers
|
|
161
|
-
* on every INSERT/UPDATE/DELETE, regardless of which connection performed the write.
|
|
162
|
-
*
|
|
163
|
-
* Combined with the in-memory counter for instant same-process detection.
|
|
164
|
-
*/
|
|
165
|
-
public _getRevision(entityName: string): string {
|
|
166
|
-
const rev = this._revisions[entityName] ?? 0;
|
|
167
|
-
const row = this.db.query('SELECT seq FROM _satidb_changes WHERE tbl = ?').get(entityName) as any;
|
|
168
|
-
const seq = row?.seq ?? 0;
|
|
169
|
-
return `${rev}:${seq}`;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ===========================================================================
|
|
173
|
-
// CRUD
|
|
174
|
-
// ===========================================================================
|
|
175
|
-
|
|
176
|
-
private insert<T extends Record<string, any>>(entityName: string, data: Omit<T, 'id'>): AugmentedEntity<any> {
|
|
177
|
-
const schema = this.schemas[entityName]!;
|
|
178
|
-
const validatedData = asZodObject(schema).passthrough().parse(data);
|
|
179
|
-
const transformed = transformForStorage(validatedData);
|
|
180
|
-
const columns = Object.keys(transformed);
|
|
181
|
-
|
|
182
|
-
const sql = columns.length === 0
|
|
183
|
-
? `INSERT INTO ${entityName} DEFAULT VALUES`
|
|
184
|
-
: `INSERT INTO ${entityName} (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
|
|
203
|
+
// =========================================================================
|
|
204
|
+
// Change Listeners — db.table.on('insert' | 'update' | 'delete', cb)
|
|
205
|
+
// =========================================================================
|
|
185
206
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Internal: get a single entity by ID */
|
|
195
|
-
private _getById(entityName: string, id: number): AugmentedEntity<any> | null {
|
|
196
|
-
const row = this.db.query(`SELECT * FROM ${entityName} WHERE id = ?`).get(id) as any;
|
|
197
|
-
if (!row) return null;
|
|
198
|
-
return this._attachMethods(entityName, transformFromStorage(row, this.schemas[entityName]!));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** Internal: get a single entity by conditions */
|
|
202
|
-
private _getOne(entityName: string, conditions: Record<string, any>): AugmentedEntity<any> | null {
|
|
203
|
-
const { clause, values } = this.buildWhereClause(conditions);
|
|
204
|
-
const row = this.db.query(`SELECT * FROM ${entityName} ${clause} LIMIT 1`).get(...values) as any;
|
|
205
|
-
if (!row) return null;
|
|
206
|
-
return this._attachMethods(entityName, transformFromStorage(row, this.schemas[entityName]!));
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Internal: find multiple entities by conditions */
|
|
210
|
-
private _findMany(entityName: string, conditions: Record<string, any> = {}): AugmentedEntity<any>[] {
|
|
211
|
-
const { clause, values } = this.buildWhereClause(conditions);
|
|
212
|
-
const rows = this.db.query(`SELECT * FROM ${entityName} ${clause}`).all(...values);
|
|
213
|
-
return rows.map((row: any) =>
|
|
214
|
-
this._attachMethods(entityName, transformFromStorage(row, this.schemas[entityName]!))
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
private update<T extends Record<string, any>>(entityName: string, id: number, data: Partial<Omit<T, 'id'>>): AugmentedEntity<any> | null {
|
|
219
|
-
const schema = this.schemas[entityName]!;
|
|
220
|
-
const validatedData = asZodObject(schema).partial().parse(data);
|
|
221
|
-
const transformed = transformForStorage(validatedData);
|
|
222
|
-
if (Object.keys(transformed).length === 0) return this._getById(entityName, id);
|
|
223
|
-
|
|
224
|
-
const setClause = Object.keys(transformed).map(key => `${key} = ?`).join(', ');
|
|
225
|
-
this.db.query(`UPDATE ${entityName} SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
|
|
226
|
-
|
|
227
|
-
this._bumpRevision(entityName);
|
|
228
|
-
const updatedEntity = this._getById(entityName, id);
|
|
229
|
-
return updatedEntity;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
private _updateWhere(entityName: string, data: Record<string, any>, conditions: Record<string, any>): number {
|
|
233
|
-
const schema = this.schemas[entityName]!;
|
|
234
|
-
const validatedData = asZodObject(schema).partial().parse(data);
|
|
235
|
-
const transformed = transformForStorage(validatedData);
|
|
236
|
-
if (Object.keys(transformed).length === 0) return 0;
|
|
237
|
-
|
|
238
|
-
const { clause, values: whereValues } = this.buildWhereClause(conditions);
|
|
239
|
-
if (!clause) throw new Error('update().where() requires at least one condition');
|
|
240
|
-
|
|
241
|
-
const setCols = Object.keys(transformed);
|
|
242
|
-
const setClause = setCols.map(key => `${key} = ?`).join(', ');
|
|
243
|
-
const result = this.db.query(`UPDATE ${entityName} SET ${setClause} ${clause}`).run(
|
|
244
|
-
...setCols.map(key => transformed[key]),
|
|
245
|
-
...whereValues
|
|
246
|
-
);
|
|
207
|
+
private _registerListener(table: string, event: ChangeEvent, callback: (row: any) => void | Promise<void>): () => void {
|
|
208
|
+
if (!this._reactive) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
'Change listeners are disabled. Set { reactive: true } (or omit it) in Database options to enable .on().'
|
|
211
|
+
);
|
|
212
|
+
}
|
|
247
213
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
214
|
+
const listener: Listener = { table, event, callback };
|
|
215
|
+
this._listeners.push(listener);
|
|
216
|
+
this._startPolling();
|
|
252
217
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
exec: () => this._updateWhere(entityName, data, _conditions),
|
|
218
|
+
return () => {
|
|
219
|
+
const idx = this._listeners.indexOf(listener);
|
|
220
|
+
if (idx >= 0) this._listeners.splice(idx, 1);
|
|
221
|
+
if (this._listeners.length === 0) this._stopPolling();
|
|
258
222
|
};
|
|
259
|
-
return builder;
|
|
260
223
|
}
|
|
261
224
|
|
|
262
|
-
private
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
? this._getById(entityName, data.id)
|
|
266
|
-
: Object.keys(conditions ?? {}).length > 0
|
|
267
|
-
? this._getOne(entityName, conditions)
|
|
268
|
-
: null;
|
|
269
|
-
|
|
270
|
-
if (existing) {
|
|
271
|
-
const updateData = { ...data };
|
|
272
|
-
delete updateData.id;
|
|
273
|
-
return this.update(entityName, existing.id, updateData) as AugmentedEntity<any>;
|
|
274
|
-
}
|
|
275
|
-
const insertData = { ...(conditions ?? {}), ...(data ?? {}) };
|
|
276
|
-
delete insertData.id;
|
|
277
|
-
return this.insert(entityName, insertData);
|
|
225
|
+
private _startPolling(): void {
|
|
226
|
+
if (this._pollTimer) return;
|
|
227
|
+
this._pollTimer = setInterval(() => this._processChanges(), this._pollInterval);
|
|
278
228
|
}
|
|
279
229
|
|
|
280
|
-
private
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
this.
|
|
284
|
-
this._bumpRevision(entityName);
|
|
230
|
+
private _stopPolling(): void {
|
|
231
|
+
if (this._pollTimer) {
|
|
232
|
+
clearInterval(this._pollTimer);
|
|
233
|
+
this._pollTimer = null;
|
|
285
234
|
}
|
|
286
235
|
}
|
|
287
236
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const belongsToRel = this.relationships.find(
|
|
310
|
-
r => r.type === 'belongs-to' && r.from === rel.to && r.to === rel.from
|
|
311
|
-
);
|
|
312
|
-
if (belongsToRel) {
|
|
313
|
-
const fk = belongsToRel.foreignKey;
|
|
314
|
-
augmented[rel.relationshipField] = () => {
|
|
315
|
-
return this._findMany(rel.to, { [fk]: entity.id });
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Auto-persist proxy: setting a field auto-updates the DB row
|
|
322
|
-
const storableFieldNames = new Set(getStorableFields(this.schemas[entityName]!).map(f => f.name));
|
|
323
|
-
return new Proxy(augmented, {
|
|
324
|
-
set: (target, prop: string, value) => {
|
|
325
|
-
if (storableFieldNames.has(prop) && target[prop] !== value) {
|
|
326
|
-
this.update(entityName, target.id, { [prop]: value });
|
|
327
|
-
}
|
|
328
|
-
target[prop] = value;
|
|
329
|
-
return true;
|
|
330
|
-
},
|
|
331
|
-
get: (target, prop, receiver) => Reflect.get(target, prop, receiver),
|
|
332
|
-
});
|
|
333
|
-
}
|
|
237
|
+
/**
|
|
238
|
+
* Core change dispatch loop.
|
|
239
|
+
*
|
|
240
|
+
* Fast path: checks MAX(id) against watermark first — if equal,
|
|
241
|
+
* there are no new changes and we skip entirely (no row materialization).
|
|
242
|
+
* Only fetches actual change rows when something has changed.
|
|
243
|
+
*/
|
|
244
|
+
private _processChanges(): void {
|
|
245
|
+
// Fast path: check if anything changed at all (single scalar, index-only)
|
|
246
|
+
const head = this.db.query('SELECT MAX(id) as m FROM "_changes"').get() as any;
|
|
247
|
+
const maxId: number = head?.m ?? 0;
|
|
248
|
+
if (maxId <= this._changeWatermark) return;
|
|
249
|
+
|
|
250
|
+
const changes = this.db.query(
|
|
251
|
+
'SELECT id, tbl, op, row_id FROM "_changes" WHERE id > ? ORDER BY id'
|
|
252
|
+
).all(this._changeWatermark) as { id: number; tbl: string; op: string; row_id: number }[];
|
|
253
|
+
|
|
254
|
+
for (const change of changes) {
|
|
255
|
+
const listeners = this._listeners.filter(
|
|
256
|
+
l => l.table === change.tbl && l.event === change.op
|
|
257
|
+
);
|
|
334
258
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
for (const branch of orBranches) {
|
|
349
|
-
const sub = this.buildWhereClause(branch, tablePrefix);
|
|
350
|
-
if (sub.clause) {
|
|
351
|
-
orParts.push(`(${sub.clause.replace(/^WHERE /, '')})`);
|
|
352
|
-
values.push(...sub.values);
|
|
259
|
+
if (listeners.length > 0) {
|
|
260
|
+
if (change.op === 'delete') {
|
|
261
|
+
// Row is gone — pass just the id
|
|
262
|
+
const payload = { id: change.row_id };
|
|
263
|
+
for (const l of listeners) {
|
|
264
|
+
try { l.callback(payload); } catch { /* listener error */ }
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
// insert or update — re-fetch the current row
|
|
268
|
+
const row = getById(this._ctx, change.tbl, change.row_id);
|
|
269
|
+
if (row) {
|
|
270
|
+
for (const l of listeners) {
|
|
271
|
+
try { l.callback(row); } catch { /* listener error */ }
|
|
353
272
|
}
|
|
354
273
|
}
|
|
355
|
-
if (orParts.length > 0) parts.push(`(${orParts.join(' OR ')})`);
|
|
356
274
|
}
|
|
357
|
-
continue;
|
|
358
275
|
}
|
|
359
|
-
const value = conditions[key];
|
|
360
|
-
const fieldName = tablePrefix ? `${tablePrefix}.${key}` : key;
|
|
361
276
|
|
|
362
|
-
|
|
363
|
-
const operator = Object.keys(value)[0];
|
|
364
|
-
if (!operator?.startsWith('$')) {
|
|
365
|
-
throw new Error(`Querying on nested object '${key}' not supported. Use operators like $gt.`);
|
|
366
|
-
}
|
|
367
|
-
const operand = value[operator];
|
|
368
|
-
|
|
369
|
-
if (operator === '$in') {
|
|
370
|
-
if (!Array.isArray(operand)) throw new Error(`$in for '${key}' requires an array`);
|
|
371
|
-
if (operand.length === 0) { parts.push('1 = 0'); continue; }
|
|
372
|
-
parts.push(`${fieldName} IN (${operand.map(() => '?').join(', ')})`);
|
|
373
|
-
values.push(...operand.map((v: any) => transformForStorage({ v }).v));
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const sqlOp = ({ $gt: '>', $gte: '>=', $lt: '<', $lte: '<=', $ne: '!=' } as Record<string, string>)[operator];
|
|
378
|
-
if (!sqlOp) throw new Error(`Unsupported operator '${operator}' on '${key}'`);
|
|
379
|
-
parts.push(`${fieldName} ${sqlOp} ?`);
|
|
380
|
-
values.push(transformForStorage({ operand }).operand);
|
|
381
|
-
} else {
|
|
382
|
-
parts.push(`${fieldName} = ?`);
|
|
383
|
-
values.push(transformForStorage({ value }).value);
|
|
384
|
-
}
|
|
277
|
+
this._changeWatermark = change.id;
|
|
385
278
|
}
|
|
386
279
|
|
|
387
|
-
|
|
280
|
+
// Clean up consumed changes
|
|
281
|
+
this.db.run('DELETE FROM "_changes" WHERE id <= ?', this._changeWatermark);
|
|
388
282
|
}
|
|
389
283
|
|
|
390
|
-
//
|
|
284
|
+
// =========================================================================
|
|
391
285
|
// Transactions
|
|
392
|
-
//
|
|
286
|
+
// =========================================================================
|
|
393
287
|
|
|
394
288
|
public transaction<T>(callback: () => T): T {
|
|
395
|
-
|
|
396
|
-
this.db.run('BEGIN TRANSACTION');
|
|
397
|
-
const result = callback();
|
|
398
|
-
this.db.run('COMMIT');
|
|
399
|
-
return result;
|
|
400
|
-
} catch (error) {
|
|
401
|
-
this.db.run('ROLLBACK');
|
|
402
|
-
throw new Error(`Transaction failed: ${(error as Error).message}`);
|
|
403
|
-
}
|
|
289
|
+
return this.db.transaction(callback)();
|
|
404
290
|
}
|
|
405
291
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
private _createQueryBuilder(entityName: string, initialCols: string[]): QueryBuilder<any> {
|
|
411
|
-
const schema = this.schemas[entityName]!;
|
|
412
|
-
|
|
413
|
-
const executor = (sql: string, params: any[], raw: boolean): any[] => {
|
|
414
|
-
const rows = this.db.query(sql).all(...params);
|
|
415
|
-
if (raw) return rows;
|
|
416
|
-
return rows.map((row: any) => this._attachMethods(entityName, transformFromStorage(row, schema)));
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
const singleExecutor = (sql: string, params: any[], raw: boolean): any | null => {
|
|
420
|
-
const results = executor(sql, params, raw);
|
|
421
|
-
return results.length > 0 ? results[0] : null;
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
const joinResolver = (fromTable: string, toTable: string): { fk: string; pk: string } | null => {
|
|
425
|
-
const belongsTo = this.relationships.find(
|
|
426
|
-
r => r.type === 'belongs-to' && r.from === fromTable && r.to === toTable
|
|
427
|
-
);
|
|
428
|
-
if (belongsTo) return { fk: belongsTo.foreignKey, pk: 'id' };
|
|
429
|
-
const reverse = this.relationships.find(
|
|
430
|
-
r => r.type === 'belongs-to' && r.from === toTable && r.to === fromTable
|
|
431
|
-
);
|
|
432
|
-
if (reverse) return { fk: 'id', pk: reverse.foreignKey };
|
|
433
|
-
return null;
|
|
434
|
-
};
|
|
435
|
-
|
|
436
|
-
// Pass revision getter — allows .subscribe() to detect ALL changes
|
|
437
|
-
const revisionGetter = () => this._getRevision(entityName);
|
|
438
|
-
|
|
439
|
-
// Condition resolver: { author: aliceEntity } → { author_id: 1 }
|
|
440
|
-
const conditionResolver = (conditions: Record<string, any>): Record<string, any> => {
|
|
441
|
-
const resolved: Record<string, any> = {};
|
|
442
|
-
for (const [key, value] of Object.entries(conditions)) {
|
|
443
|
-
// Detect entity references: objects with `id` and `delete` (augmented entities)
|
|
444
|
-
if (value && typeof value === 'object' && typeof value.id === 'number' && typeof value.delete === 'function') {
|
|
445
|
-
// Find a belongs-to relationship: entityName has a FK named `key_id` pointing to another table
|
|
446
|
-
const fkCol = key + '_id';
|
|
447
|
-
const rel = this.relationships.find(
|
|
448
|
-
r => r.type === 'belongs-to' && r.from === entityName && r.foreignKey === fkCol
|
|
449
|
-
);
|
|
450
|
-
if (rel) {
|
|
451
|
-
resolved[fkCol] = value.id;
|
|
452
|
-
} else {
|
|
453
|
-
// Fallback: try any relationship that matches the key as the nav name
|
|
454
|
-
const relByNav = this.relationships.find(
|
|
455
|
-
r => r.type === 'belongs-to' && r.from === entityName && r.to === key + 's'
|
|
456
|
-
) || this.relationships.find(
|
|
457
|
-
r => r.type === 'belongs-to' && r.from === entityName && r.to === key
|
|
458
|
-
);
|
|
459
|
-
if (relByNav) {
|
|
460
|
-
resolved[relByNav.foreignKey] = value.id;
|
|
461
|
-
} else {
|
|
462
|
-
resolved[key] = value; // pass through
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
} else {
|
|
466
|
-
resolved[key] = value;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
return resolved;
|
|
470
|
-
};
|
|
471
|
-
|
|
472
|
-
// Eager loader: resolves .with('books') → batch load children
|
|
473
|
-
const eagerLoader = (parentTable: string, relation: string, parentIds: number[]): { key: string; groups: Map<number, any[]> } | null => {
|
|
474
|
-
// 1. Try one-to-many: parentTable has-many relation (e.g., authors → books)
|
|
475
|
-
const hasMany = this.relationships.find(
|
|
476
|
-
r => r.type === 'one-to-many' && r.from === parentTable && r.relationshipField === relation
|
|
477
|
-
);
|
|
478
|
-
if (hasMany) {
|
|
479
|
-
// Find the belongs-to FK on the child table
|
|
480
|
-
const belongsTo = this.relationships.find(
|
|
481
|
-
r => r.type === 'belongs-to' && r.from === hasMany.to && r.to === parentTable
|
|
482
|
-
);
|
|
483
|
-
if (belongsTo) {
|
|
484
|
-
const fk = belongsTo.foreignKey;
|
|
485
|
-
const placeholders = parentIds.map(() => '?').join(', ');
|
|
486
|
-
const childRows = this.db.query(
|
|
487
|
-
`SELECT * FROM ${hasMany.to} WHERE ${fk} IN (${placeholders})`
|
|
488
|
-
).all(...parentIds) as any[];
|
|
489
|
-
|
|
490
|
-
const groups = new Map<number, any[]>();
|
|
491
|
-
const childSchema = this.schemas[hasMany.to]!;
|
|
492
|
-
for (const rawRow of childRows) {
|
|
493
|
-
const entity = this._attachMethods(
|
|
494
|
-
hasMany.to,
|
|
495
|
-
transformFromStorage(rawRow, childSchema)
|
|
496
|
-
);
|
|
497
|
-
const parentId = rawRow[fk] as number;
|
|
498
|
-
if (!groups.has(parentId)) groups.set(parentId, []);
|
|
499
|
-
groups.get(parentId)!.push(entity);
|
|
500
|
-
}
|
|
501
|
-
return { key: relation, groups };
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// 2. Try belongs-to: parentTable belongs-to relation (e.g., books → author)
|
|
506
|
-
const belongsTo = this.relationships.find(
|
|
507
|
-
r => r.type === 'belongs-to' && r.from === parentTable && r.relationshipField === relation
|
|
508
|
-
);
|
|
509
|
-
if (belongsTo) {
|
|
510
|
-
// Load parent entities and map by id
|
|
511
|
-
const fkValues = [...new Set(parentIds)];
|
|
512
|
-
// Actually we need FK values from parent rows, not parent IDs
|
|
513
|
-
// This case is trickier — skip for now, belongs-to is already handled by lazy nav
|
|
514
|
-
return null;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
return null;
|
|
518
|
-
};
|
|
519
|
-
|
|
520
|
-
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter, eagerLoader, this.pollInterval);
|
|
521
|
-
if (initialCols.length > 0) builder.select(...initialCols);
|
|
522
|
-
return builder;
|
|
292
|
+
/** Close the database: stops polling and releases the SQLite handle. */
|
|
293
|
+
public close(): void {
|
|
294
|
+
this._stopPolling();
|
|
295
|
+
this.db.close();
|
|
523
296
|
}
|
|
524
297
|
|
|
298
|
+
// =========================================================================
|
|
299
|
+
// Proxy Query
|
|
300
|
+
// =========================================================================
|
|
301
|
+
|
|
525
302
|
/** Proxy callback query for complex SQL-like JOINs */
|
|
526
303
|
public query<T extends Record<string, any> = Record<string, any>>(
|
|
527
304
|
callback: (ctx: { [K in keyof Schemas]: ProxyColumns<InferSchema<Schemas[K]>> }) => ProxyQueryResult
|