sqlite-zod-orm 3.3.1 → 3.4.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 +35 -5
- package/dist/index.js +47 -3
- package/package.json +1 -1
- package/src/database.ts +83 -1
- package/src/query-builder.ts +7 -2
- package/src/types.ts +12 -0
package/README.md
CHANGED
|
@@ -207,9 +207,37 @@ const db = new Database(':memory:', schemas, {
|
|
|
207
207
|
|
|
208
208
|
---
|
|
209
209
|
|
|
210
|
-
## Reactivity
|
|
210
|
+
## Reactivity
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
Two complementary APIs for watching data changes:
|
|
213
|
+
|
|
214
|
+
| API | Receives | Fires on | Use case |
|
|
215
|
+
|---|---|---|---|
|
|
216
|
+
| **`db.table.on(cb)`** | One row at a time, in order | New inserts only | Message streams, event queues |
|
|
217
|
+
| **`select().subscribe(cb)`** | Full result snapshot | Any change (insert/update/delete) | Live dashboards, filtered views |
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
### Row Stream — `db.table.on(callback)`
|
|
222
|
+
|
|
223
|
+
Streams new rows one at a time, in insertion order. Only emits rows created **after** subscription starts.
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
const unsub = db.messages.on((msg) => {
|
|
227
|
+
console.log(`${msg.author}: ${msg.text}`);
|
|
228
|
+
}, { interval: 200 });
|
|
229
|
+
|
|
230
|
+
// Later: stop listening
|
|
231
|
+
unsub();
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
If 5 messages arrive between polls, the callback fires 5 times — once per row, in order. Uses a watermark (`id > lastSeen`) internally.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
### Snapshot — `select().subscribe(callback)`
|
|
239
|
+
|
|
240
|
+
Returns the **full query result** whenever data changes. Detects all mutations (inserts, updates, deletes).
|
|
213
241
|
|
|
214
242
|
```typescript
|
|
215
243
|
const unsub = db.users.select()
|
|
@@ -291,8 +319,9 @@ Run `bun examples/messages-demo.ts` for a full working demo.
|
|
|
291
319
|
## Examples & Tests
|
|
292
320
|
|
|
293
321
|
```bash
|
|
294
|
-
bun examples/
|
|
295
|
-
bun
|
|
322
|
+
bun examples/messages-demo.ts # .on() vs .subscribe() demo
|
|
323
|
+
bun examples/example.ts # comprehensive demo
|
|
324
|
+
bun test # 93 tests
|
|
296
325
|
```
|
|
297
326
|
|
|
298
327
|
---
|
|
@@ -319,7 +348,8 @@ bun test # 91 tests
|
|
|
319
348
|
| `entity.update(data)` | Update entity in-place |
|
|
320
349
|
| `entity.delete()` | Delete entity |
|
|
321
350
|
| **Reactivity** | |
|
|
322
|
-
| `
|
|
351
|
+
| `db.table.on(cb, opts?)` | Stream new rows one at a time, in order |
|
|
352
|
+
| `select().subscribe(cb, opts?)` | Watch query result snapshot (all mutations) |
|
|
323
353
|
|
|
324
354
|
## License
|
|
325
355
|
|
package/dist/index.js
CHANGED
|
@@ -112,6 +112,8 @@ function compileIQO(tableName, iqo) {
|
|
|
112
112
|
sql += ` WHERE ${compiled.sql}`;
|
|
113
113
|
params.push(...compiled.params);
|
|
114
114
|
} else if (iqo.wheres.length > 0) {
|
|
115
|
+
const hasJoins = iqo.joins.length > 0;
|
|
116
|
+
const qualify = (field) => hasJoins && !field.includes(".") ? `${tableName}.${field}` : field;
|
|
115
117
|
const whereParts = [];
|
|
116
118
|
for (const w of iqo.wheres) {
|
|
117
119
|
if (w.operator === "IN") {
|
|
@@ -120,11 +122,11 @@ function compileIQO(tableName, iqo) {
|
|
|
120
122
|
whereParts.push("1 = 0");
|
|
121
123
|
} else {
|
|
122
124
|
const placeholders = arr.map(() => "?").join(", ");
|
|
123
|
-
whereParts.push(`${w.field} IN (${placeholders})`);
|
|
125
|
+
whereParts.push(`${qualify(w.field)} IN (${placeholders})`);
|
|
124
126
|
params.push(...arr.map(transformValueForStorage));
|
|
125
127
|
}
|
|
126
128
|
} else {
|
|
127
|
-
whereParts.push(`${w.field} ${w.operator} ?`);
|
|
129
|
+
whereParts.push(`${qualify(w.field)} ${w.operator} ?`);
|
|
128
130
|
params.push(transformValueForStorage(w.value));
|
|
129
131
|
}
|
|
130
132
|
}
|
|
@@ -4680,6 +4682,7 @@ class _Database {
|
|
|
4680
4682
|
upsert: (conditions, data) => this.upsert(entityName, data, conditions),
|
|
4681
4683
|
delete: (id) => this.delete(entityName, id),
|
|
4682
4684
|
select: (...cols) => this._createQueryBuilder(entityName, cols),
|
|
4685
|
+
on: (callback, options2) => this._createOnStream(entityName, callback, options2),
|
|
4683
4686
|
_tableName: entityName
|
|
4684
4687
|
};
|
|
4685
4688
|
this[key] = accessor;
|
|
@@ -4729,6 +4732,25 @@ class _Database {
|
|
|
4729
4732
|
const dataVersion = this.db.query("PRAGMA data_version").get()?.data_version ?? 0;
|
|
4730
4733
|
return `${rev}:${dataVersion}`;
|
|
4731
4734
|
}
|
|
4735
|
+
_createOnStream(entityName, callback, options) {
|
|
4736
|
+
const { interval = 500 } = options ?? {};
|
|
4737
|
+
const maxRow = this.db.query(`SELECT MAX(id) as _max FROM "${entityName}"`).get();
|
|
4738
|
+
let lastMaxId = maxRow?._max ?? 0;
|
|
4739
|
+
let lastRevision = this._getRevision(entityName);
|
|
4740
|
+
const timer = setInterval(() => {
|
|
4741
|
+
const currentRevision = this._getRevision(entityName);
|
|
4742
|
+
if (currentRevision === lastRevision)
|
|
4743
|
+
return;
|
|
4744
|
+
lastRevision = currentRevision;
|
|
4745
|
+
const newRows = this.db.query(`SELECT * FROM "${entityName}" WHERE id > ? ORDER BY id ASC`).all(lastMaxId);
|
|
4746
|
+
for (const rawRow of newRows) {
|
|
4747
|
+
const entity = this._attachMethods(entityName, transformFromStorage(rawRow, this.schemas[entityName]));
|
|
4748
|
+
callback(entity);
|
|
4749
|
+
lastMaxId = rawRow.id;
|
|
4750
|
+
}
|
|
4751
|
+
}, interval);
|
|
4752
|
+
return () => clearInterval(timer);
|
|
4753
|
+
}
|
|
4732
4754
|
insert(entityName, data) {
|
|
4733
4755
|
const schema = this.schemas[entityName];
|
|
4734
4756
|
const validatedData = asZodObject(schema).passthrough().parse(data);
|
|
@@ -4935,7 +4957,29 @@ class _Database {
|
|
|
4935
4957
|
return null;
|
|
4936
4958
|
};
|
|
4937
4959
|
const revisionGetter = () => this._getRevision(entityName);
|
|
4938
|
-
const
|
|
4960
|
+
const conditionResolver = (conditions) => {
|
|
4961
|
+
const resolved = {};
|
|
4962
|
+
for (const [key, value] of Object.entries(conditions)) {
|
|
4963
|
+
if (value && typeof value === "object" && typeof value.id === "number" && typeof value.delete === "function") {
|
|
4964
|
+
const fkCol = key + "_id";
|
|
4965
|
+
const rel = this.relationships.find((r) => r.type === "belongs-to" && r.from === entityName && r.foreignKey === fkCol);
|
|
4966
|
+
if (rel) {
|
|
4967
|
+
resolved[fkCol] = value.id;
|
|
4968
|
+
} else {
|
|
4969
|
+
const relByNav = this.relationships.find((r) => r.type === "belongs-to" && r.from === entityName && r.to === key + "s") || this.relationships.find((r) => r.type === "belongs-to" && r.from === entityName && r.to === key);
|
|
4970
|
+
if (relByNav) {
|
|
4971
|
+
resolved[relByNav.foreignKey] = value.id;
|
|
4972
|
+
} else {
|
|
4973
|
+
resolved[key] = value;
|
|
4974
|
+
}
|
|
4975
|
+
}
|
|
4976
|
+
} else {
|
|
4977
|
+
resolved[key] = value;
|
|
4978
|
+
}
|
|
4979
|
+
}
|
|
4980
|
+
return resolved;
|
|
4981
|
+
};
|
|
4982
|
+
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter);
|
|
4939
4983
|
if (initialCols.length > 0)
|
|
4940
4984
|
builder.select(...initialCols);
|
|
4941
4985
|
return builder;
|
package/package.json
CHANGED
package/src/database.ts
CHANGED
|
@@ -57,6 +57,8 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
57
57
|
upsert: (conditions, data) => this.upsert(entityName, data, conditions),
|
|
58
58
|
delete: (id) => this.delete(entityName, id),
|
|
59
59
|
select: (...cols: string[]) => this._createQueryBuilder(entityName, cols),
|
|
60
|
+
on: (callback: (row: any) => void, options?: { interval?: number }) =>
|
|
61
|
+
this._createOnStream(entityName, callback, options),
|
|
60
62
|
_tableName: entityName,
|
|
61
63
|
};
|
|
62
64
|
(this as any)[key] = accessor;
|
|
@@ -137,6 +139,53 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
137
139
|
return `${rev}:${dataVersion}`;
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
// ===========================================================================
|
|
143
|
+
// Row Stream — .on(callback)
|
|
144
|
+
// ===========================================================================
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Stream new rows one at a time, in insertion order.
|
|
148
|
+
*
|
|
149
|
+
* Uses a watermark (last seen id) to query only `WHERE id > ?`.
|
|
150
|
+
* Checks revision + data_version first to avoid unnecessary queries.
|
|
151
|
+
*/
|
|
152
|
+
public _createOnStream(
|
|
153
|
+
entityName: string,
|
|
154
|
+
callback: (row: any) => void,
|
|
155
|
+
options?: { interval?: number },
|
|
156
|
+
): () => void {
|
|
157
|
+
const { interval = 500 } = options ?? {};
|
|
158
|
+
|
|
159
|
+
// Initialize watermark to current max id (only emit NEW rows)
|
|
160
|
+
const maxRow = this.db.query(`SELECT MAX(id) as _max FROM "${entityName}"`).get() as any;
|
|
161
|
+
let lastMaxId: number = maxRow?._max ?? 0;
|
|
162
|
+
let lastRevision: string = this._getRevision(entityName);
|
|
163
|
+
|
|
164
|
+
const timer = setInterval(() => {
|
|
165
|
+
// Fast check: did anything change?
|
|
166
|
+
const currentRevision = this._getRevision(entityName);
|
|
167
|
+
if (currentRevision === lastRevision) return;
|
|
168
|
+
lastRevision = currentRevision;
|
|
169
|
+
|
|
170
|
+
// Fetch new rows since watermark
|
|
171
|
+
const newRows = this.db.query(
|
|
172
|
+
`SELECT * FROM "${entityName}" WHERE id > ? ORDER BY id ASC`
|
|
173
|
+
).all(lastMaxId) as any[];
|
|
174
|
+
|
|
175
|
+
for (const rawRow of newRows) {
|
|
176
|
+
// Hydrate with entity methods and schema transforms
|
|
177
|
+
const entity = this._attachMethods(
|
|
178
|
+
entityName,
|
|
179
|
+
transformFromStorage(rawRow, this.schemas[entityName]!)
|
|
180
|
+
);
|
|
181
|
+
callback(entity);
|
|
182
|
+
lastMaxId = rawRow.id;
|
|
183
|
+
}
|
|
184
|
+
}, interval);
|
|
185
|
+
|
|
186
|
+
return () => clearInterval(timer);
|
|
187
|
+
}
|
|
188
|
+
|
|
140
189
|
// ===========================================================================
|
|
141
190
|
// CRUD
|
|
142
191
|
// ===========================================================================
|
|
@@ -404,7 +453,40 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
404
453
|
// Pass revision getter — allows .subscribe() to detect ALL changes
|
|
405
454
|
const revisionGetter = () => this._getRevision(entityName);
|
|
406
455
|
|
|
407
|
-
|
|
456
|
+
// Condition resolver: { author: aliceEntity } → { author_id: 1 }
|
|
457
|
+
const conditionResolver = (conditions: Record<string, any>): Record<string, any> => {
|
|
458
|
+
const resolved: Record<string, any> = {};
|
|
459
|
+
for (const [key, value] of Object.entries(conditions)) {
|
|
460
|
+
// Detect entity references: objects with `id` and `delete` (augmented entities)
|
|
461
|
+
if (value && typeof value === 'object' && typeof value.id === 'number' && typeof value.delete === 'function') {
|
|
462
|
+
// Find a belongs-to relationship: entityName has a FK named `key_id` pointing to another table
|
|
463
|
+
const fkCol = key + '_id';
|
|
464
|
+
const rel = this.relationships.find(
|
|
465
|
+
r => r.type === 'belongs-to' && r.from === entityName && r.foreignKey === fkCol
|
|
466
|
+
);
|
|
467
|
+
if (rel) {
|
|
468
|
+
resolved[fkCol] = value.id;
|
|
469
|
+
} else {
|
|
470
|
+
// Fallback: try any relationship that matches the key as the nav name
|
|
471
|
+
const relByNav = this.relationships.find(
|
|
472
|
+
r => r.type === 'belongs-to' && r.from === entityName && r.to === key + 's'
|
|
473
|
+
) || this.relationships.find(
|
|
474
|
+
r => r.type === 'belongs-to' && r.from === entityName && r.to === key
|
|
475
|
+
);
|
|
476
|
+
if (relByNav) {
|
|
477
|
+
resolved[relByNav.foreignKey] = value.id;
|
|
478
|
+
} else {
|
|
479
|
+
resolved[key] = value; // pass through
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} else {
|
|
483
|
+
resolved[key] = value;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return resolved;
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, revisionGetter);
|
|
408
490
|
if (initialCols.length > 0) builder.select(...initialCols);
|
|
409
491
|
return builder;
|
|
410
492
|
}
|
package/src/query-builder.ts
CHANGED
|
@@ -86,6 +86,11 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
|
|
|
86
86
|
sql += ` WHERE ${compiled.sql}`;
|
|
87
87
|
params.push(...compiled.params);
|
|
88
88
|
} else if (iqo.wheres.length > 0) {
|
|
89
|
+
const hasJoins = iqo.joins.length > 0;
|
|
90
|
+
// When joins exist, qualify bare column names with the main table
|
|
91
|
+
const qualify = (field: string) =>
|
|
92
|
+
hasJoins && !field.includes('.') ? `${tableName}.${field}` : field;
|
|
93
|
+
|
|
89
94
|
const whereParts: string[] = [];
|
|
90
95
|
for (const w of iqo.wheres) {
|
|
91
96
|
if (w.operator === 'IN') {
|
|
@@ -94,11 +99,11 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
|
|
|
94
99
|
whereParts.push('1 = 0');
|
|
95
100
|
} else {
|
|
96
101
|
const placeholders = arr.map(() => '?').join(', ');
|
|
97
|
-
whereParts.push(`${w.field} IN (${placeholders})`);
|
|
102
|
+
whereParts.push(`${qualify(w.field)} IN (${placeholders})`);
|
|
98
103
|
params.push(...arr.map(transformValueForStorage));
|
|
99
104
|
}
|
|
100
105
|
} else {
|
|
101
|
-
whereParts.push(`${w.field} ${w.operator} ?`);
|
|
106
|
+
whereParts.push(`${qualify(w.field)} ${w.operator} ?`);
|
|
102
107
|
params.push(transformValueForStorage(w.value));
|
|
103
108
|
}
|
|
104
109
|
}
|
package/src/types.ts
CHANGED
|
@@ -143,6 +143,12 @@ export type NavEntityAccessor<
|
|
|
143
143
|
upsert: (conditions?: Partial<z.infer<S[Table & keyof S]>>, data?: Partial<z.infer<S[Table & keyof S]>>) => NavEntity<S, R, Table>;
|
|
144
144
|
delete: (id: number) => void;
|
|
145
145
|
select: (...cols: (keyof z.infer<S[Table & keyof S]> & string)[]) => QueryBuilder<NavEntity<S, R, Table>>;
|
|
146
|
+
/**
|
|
147
|
+
* Stream new rows one at a time, in insertion order.
|
|
148
|
+
* Only emits rows inserted AFTER subscription starts.
|
|
149
|
+
* @returns Unsubscribe function.
|
|
150
|
+
*/
|
|
151
|
+
on: (callback: (row: NavEntity<S, R, Table>) => void, options?: { interval?: number }) => () => void;
|
|
146
152
|
_tableName: string;
|
|
147
153
|
readonly _schema?: S[Table & keyof S];
|
|
148
154
|
};
|
|
@@ -168,6 +174,12 @@ export type EntityAccessor<S extends z.ZodType<any>> = {
|
|
|
168
174
|
upsert: (conditions?: Partial<InferSchema<S>>, data?: Partial<InferSchema<S>>) => AugmentedEntity<S>;
|
|
169
175
|
delete: (id: number) => void;
|
|
170
176
|
select: (...cols: (keyof InferSchema<S> & string)[]) => QueryBuilder<AugmentedEntity<S>>;
|
|
177
|
+
/**
|
|
178
|
+
* Stream new rows one at a time, in insertion order.
|
|
179
|
+
* Only emits rows inserted AFTER subscription starts.
|
|
180
|
+
* @returns Unsubscribe function.
|
|
181
|
+
*/
|
|
182
|
+
on: (callback: (row: AugmentedEntity<S>) => void, options?: { interval?: number }) => () => void;
|
|
171
183
|
_tableName: string;
|
|
172
184
|
readonly _schema?: S;
|
|
173
185
|
};
|