@tylix/orm 0.6.0 → 1.0.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/LICENSE +21 -0
- package/README.md +40 -15
- package/package.json +33 -6
- package/src/adapters/DatabaseAdapter.d.ts +6 -0
- package/src/adapters/DatabaseAdapter.js +20 -0
- package/src/adapters/MongoAdapter.js +11 -0
- package/src/adapters/MysqlAdapter.js +6 -0
- package/src/adapters/PostgresAdapter.js +8 -1
- package/src/adapters/SqliteAdapter.js +54 -1
- package/src/index.d.ts +3 -1
- package/src/index.js +3 -0
- package/src/instrumentation.d.ts +10 -0
- package/src/instrumentation.js +26 -0
- package/src/model/Model.d.ts +21 -0
- package/src/model/Model.js +39 -4
- package/src/model/observers.js +83 -0
- package/src/model/traits.d.ts +30 -0
- package/src/model/traits.js +61 -0
- package/src/adapters/AdapterFactory.test.js +0 -35
- package/src/adapters/MongoAdapter.test.js +0 -52
- package/src/adapters/MysqlAdapter.test.js +0 -36
- package/src/adapters/PostgresAdapter.test.js +0 -47
- package/src/adapters/SqliteAdapter.test.js +0 -39
- package/src/adapters/query.test.js +0 -60
- package/src/model/Model.paginate.test.js +0 -74
- package/src/model/Model.relations.test.js +0 -86
- package/src/model/Model.test.js +0 -91
- package/src/query/QueryBuilder.test.js +0 -82
- package/src/query/buildSelectSql.test.js +0 -58
- package/tsconfig.json +0 -14
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mbiyrukzz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,29 +1,54 @@
|
|
|
1
1
|
# @tylix/orm
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
PostgreSQL, MySQL
|
|
3
|
+
Models, a query builder and migrations-ready adapters for SQLite,
|
|
4
|
+
PostgreSQL, MySQL and MongoDB.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
```sh
|
|
7
|
+
npm install @tylix/orm
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Models
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { Model } from '@tylix/orm'
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
export class Post extends Model {
|
|
16
|
+
static table = 'posts'
|
|
17
|
+
static fillable = ['title', 'body', 'user_id']
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const post = await Post.create({ title: 'Hello', body: '...', user_id: 1 })
|
|
21
|
+
const recent = await Post.where('user_id', 1).latest().limit(10).get()
|
|
22
|
+
const page = await Post.paginate({ page: 2, limit: 20 })
|
|
23
|
+
const claimed = await Job.updateWhere({ id, status: 'pending' }, { status: 'processing' }) // 1 or 0
|
|
24
|
+
```
|
|
14
25
|
|
|
15
|
-
##
|
|
26
|
+
## Observers and traits
|
|
16
27
|
|
|
17
28
|
```js
|
|
18
|
-
import { Model,
|
|
29
|
+
import { Model, Searchable, use } from '@tylix/orm'
|
|
19
30
|
|
|
20
|
-
class
|
|
21
|
-
static table = '
|
|
31
|
+
class Post extends use(Model, Searchable({ fields: ['title', 'body'] })) {
|
|
32
|
+
static table = 'posts'
|
|
33
|
+
static observers = [PostObserver] // creating/created, updating/updated, deleting/deleted
|
|
22
34
|
}
|
|
35
|
+
|
|
36
|
+
const results = await Post.search('tylix')
|
|
23
37
|
```
|
|
24
38
|
|
|
25
|
-
|
|
26
|
-
|
|
39
|
+
## Query timing
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
import { onQuery } from '@tylix/orm'
|
|
43
|
+
|
|
44
|
+
onQuery(({ driver, sql, durationMs }) => { /* log, measure */ })
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The Tylix CLI uses this for slow-query warnings and metrics. Nothing is
|
|
48
|
+
timed while no one listens.
|
|
49
|
+
|
|
50
|
+
SQLite statements are prepared once and reused; a migration run by
|
|
51
|
+
another process is picked up within a second.
|
|
27
52
|
|
|
28
53
|
## License
|
|
29
54
|
|
package/package.json
CHANGED
|
@@ -1,22 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tylix/orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The Tylix ORM: models with observers and traits, a query builder, and adapters for SQLite, PostgreSQL, MySQL and MongoDB.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Mbiyrukzz",
|
|
4
7
|
"type": "module",
|
|
5
|
-
"main": "./src/index.js",
|
|
6
|
-
"types": "./src/index.d.ts",
|
|
7
8
|
"keywords": [
|
|
8
9
|
"tylix",
|
|
9
10
|
"orm",
|
|
10
11
|
"database",
|
|
11
12
|
"mongodb",
|
|
12
13
|
"mysql",
|
|
13
|
-
"postgresql"
|
|
14
|
+
"postgresql",
|
|
15
|
+
"sqlite",
|
|
16
|
+
"postgres"
|
|
14
17
|
],
|
|
18
|
+
"homepage": "https://github.com/Mbiyrukzz/tylix/tree/main/packages/orm#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/Mbiyrukzz/tylix.git",
|
|
22
|
+
"directory": "packages/orm"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/Mbiyrukzz/tylix/issues"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.13"
|
|
29
|
+
},
|
|
30
|
+
"main": "./src/index.js",
|
|
31
|
+
"types": "./src/index.d.ts",
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"!**/*.test.js",
|
|
35
|
+
"!**/*.test.mjs",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
15
42
|
"scripts": {
|
|
16
|
-
"test": "node --test src/**/*.test.js"
|
|
43
|
+
"test": "node --test 'src/**/*.test.js'"
|
|
17
44
|
},
|
|
18
45
|
"dependencies": {
|
|
19
|
-
"@tylix/shared": "^0.
|
|
46
|
+
"@tylix/shared": "^1.0.0",
|
|
20
47
|
"mongodb": "^6.5.0",
|
|
21
48
|
"mysql2": "^3.23.1",
|
|
22
49
|
"pg": "^8.11.0"
|
|
@@ -8,6 +8,12 @@ export declare class DatabaseAdapter {
|
|
|
8
8
|
/** SQL keyword used for LIKE conditions: "LIKE" by default, "ILIKE" on Postgres. */
|
|
9
9
|
readonly likeOperator: 'LIKE' | 'ILIKE';
|
|
10
10
|
|
|
11
|
+
/** Updates rows matching every field of `where` in one statement; resolves to how many changed. */
|
|
12
|
+
updateWhere(table: string, values: Record<string, unknown>, where: Record<string, unknown>): Promise<number>;
|
|
13
|
+
|
|
14
|
+
/** Resolves when the database answers (SELECT 1, or a ping on MongoDB). */
|
|
15
|
+
ping(): Promise<void>;
|
|
16
|
+
|
|
11
17
|
connect(): Promise<this>;
|
|
12
18
|
close(): Promise<void>;
|
|
13
19
|
run(sql: string, params?: unknown[]): Promise<RunResult>;
|
|
@@ -32,6 +32,26 @@ export class DatabaseAdapter {
|
|
|
32
32
|
throw new Error('columnType() not implemented')
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Updates the rows matching every field in `where`; resolves to how
|
|
36
|
+
// many rows changed. One statement, so a check-and-set such as
|
|
37
|
+
// { status: 'pending' } -> { status: 'processing' } is atomic.
|
|
38
|
+
async updateWhere(table, values, where) {
|
|
39
|
+
const q = (name) => this.quoteIdentifier(name)
|
|
40
|
+
const sets = Object.keys(values).map((column) => `${q(column)} = ?`)
|
|
41
|
+
const conditions = Object.keys(where).map((column) => `${q(column)} = ?`)
|
|
42
|
+
if (sets.length === 0 || conditions.length === 0) throw new Error('updateWhere() needs values and at least one condition.')
|
|
43
|
+
const result = await this.run(`UPDATE ${q(table)} SET ${sets.join(', ')} WHERE ${conditions.join(' AND ')}`, [
|
|
44
|
+
...Object.values(values),
|
|
45
|
+
...Object.values(where),
|
|
46
|
+
])
|
|
47
|
+
return Number(result?.changes ?? result?.rowCount ?? result?.affectedRows ?? 0)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Resolves when the database answers; used by /ready.
|
|
51
|
+
async ping() {
|
|
52
|
+
await this.get('SELECT 1')
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
async count(table) {
|
|
36
56
|
throw new Error('count() not implemented')
|
|
37
57
|
}
|
|
@@ -111,6 +111,17 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
111
111
|
this.counters = {}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
async updateWhere(table, values, where) {
|
|
115
|
+
this.ensureConnected()
|
|
116
|
+
const result = await this.db.collection(table).updateMany({ ...where }, { $set: { ...values } })
|
|
117
|
+
return result.modifiedCount
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async ping() {
|
|
121
|
+
this.ensureConnected()
|
|
122
|
+
await this.db.command({ ping: 1 })
|
|
123
|
+
}
|
|
124
|
+
|
|
114
125
|
async connect() {
|
|
115
126
|
try {
|
|
116
127
|
// Fail after 5s instead of the driver's default 30s, so a stopped
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import mysql from 'mysql2/promise'
|
|
2
2
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
3
3
|
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
4
|
+
import { emitQuery, hasQueryListeners } from '../instrumentation.js'
|
|
4
5
|
|
|
5
6
|
const COLUMN_TYPES = {
|
|
6
7
|
increments: 'INT AUTO_INCREMENT PRIMARY KEY',
|
|
@@ -10,6 +11,7 @@ const COLUMN_TYPES = {
|
|
|
10
11
|
longText: 'LONGTEXT',
|
|
11
12
|
boolean: 'TINYINT(1)',
|
|
12
13
|
integer: 'INT',
|
|
14
|
+
float: 'DOUBLE',
|
|
13
15
|
date: 'DATE',
|
|
14
16
|
datetime: 'DATETIME',
|
|
15
17
|
json: 'JSON',
|
|
@@ -77,10 +79,14 @@ export class MysqlAdapter extends DatabaseAdapter {
|
|
|
77
79
|
async execute(sql, params = []) {
|
|
78
80
|
this.ensureConnected()
|
|
79
81
|
const converted = params.map(toMysqlDateTime)
|
|
82
|
+
const timed = hasQueryListeners()
|
|
83
|
+
const start = timed ? performance.now() : 0
|
|
80
84
|
try {
|
|
81
85
|
const [result] = await this.connection.execute(sql, converted)
|
|
86
|
+
if (timed) emitQuery({ driver: 'mysql', sql, durationMs: performance.now() - start })
|
|
82
87
|
return result
|
|
83
88
|
} catch (err) {
|
|
89
|
+
if (timed) emitQuery({ driver: 'mysql', sql, durationMs: performance.now() - start, error: err })
|
|
84
90
|
throw normalizeDatabaseError('mysql', err, { sql, params: converted })
|
|
85
91
|
}
|
|
86
92
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pg from 'pg'
|
|
2
2
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
3
3
|
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
4
|
+
import { emitQuery, hasQueryListeners } from '../instrumentation.js'
|
|
4
5
|
|
|
5
6
|
const COLUMN_TYPES = {
|
|
6
7
|
increments: 'SERIAL PRIMARY KEY',
|
|
@@ -8,6 +9,7 @@ const COLUMN_TYPES = {
|
|
|
8
9
|
text: 'TEXT',
|
|
9
10
|
boolean: 'BOOLEAN',
|
|
10
11
|
integer: 'INTEGER',
|
|
12
|
+
float: 'DOUBLE PRECISION',
|
|
11
13
|
date: 'DATE',
|
|
12
14
|
datetime: 'TIMESTAMP',
|
|
13
15
|
json: 'JSONB',
|
|
@@ -78,9 +80,14 @@ export class PostgresAdapter extends DatabaseAdapter {
|
|
|
78
80
|
// SQL (with $1, $2 placeholders), exactly what PostgreSQL received.
|
|
79
81
|
async execute(sql, params = []) {
|
|
80
82
|
this.ensureConnected()
|
|
83
|
+
const timed = hasQueryListeners()
|
|
84
|
+
const start = timed ? performance.now() : 0
|
|
81
85
|
try {
|
|
82
|
-
|
|
86
|
+
const result = await this.pool.query(sql, params)
|
|
87
|
+
if (timed) emitQuery({ driver: 'postgres', sql, durationMs: performance.now() - start })
|
|
88
|
+
return result
|
|
83
89
|
} catch (err) {
|
|
90
|
+
if (timed) emitQuery({ driver: 'postgres', sql, durationMs: performance.now() - start, error: err })
|
|
84
91
|
throw normalizeDatabaseError('postgres', err, { sql, params })
|
|
85
92
|
}
|
|
86
93
|
}
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
2
2
|
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
3
|
+
import { emitQuery, hasQueryListeners } from '../instrumentation.js'
|
|
4
|
+
|
|
5
|
+
// Prepared statements kept per connection, so a repeated query skips
|
|
6
|
+
// SQLite's parse and plan step. Bounded: the oldest is dropped first.
|
|
7
|
+
// A prepared "SELECT *" keeps the columns it was prepared with, so the
|
|
8
|
+
// cache is cleared when the schema changes: at once for DDL run here, and
|
|
9
|
+
// within a second for a migration run by another process.
|
|
10
|
+
const STATEMENT_CACHE_SIZE = 500
|
|
11
|
+
const SCHEMA_CHECK_MS = 1000
|
|
12
|
+
const DDL = /^\s*(CREATE|ALTER|DROP)\b/i
|
|
3
13
|
|
|
4
14
|
const COLUMN_TYPES = {
|
|
5
15
|
increments: 'INTEGER PRIMARY KEY AUTOINCREMENT',
|
|
@@ -7,6 +17,7 @@ const COLUMN_TYPES = {
|
|
|
7
17
|
text: 'TEXT',
|
|
8
18
|
boolean: 'INTEGER',
|
|
9
19
|
integer: 'INTEGER',
|
|
20
|
+
float: 'REAL',
|
|
10
21
|
date: 'TEXT',
|
|
11
22
|
datetime: 'TEXT',
|
|
12
23
|
json: 'TEXT',
|
|
@@ -18,6 +29,9 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
18
29
|
super()
|
|
19
30
|
this.filename = filename
|
|
20
31
|
this.db = null
|
|
32
|
+
this.statements = new Map()
|
|
33
|
+
this.schemaVersion = null
|
|
34
|
+
this.schemaCheckedAt = 0
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
async connect() {
|
|
@@ -34,6 +48,7 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
34
48
|
|
|
35
49
|
async close() {
|
|
36
50
|
if (this.db) {
|
|
51
|
+
this.statements.clear()
|
|
37
52
|
this.db.close()
|
|
38
53
|
this.db = null
|
|
39
54
|
}
|
|
@@ -54,13 +69,51 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
54
69
|
// is prepared, so prepare() is inside the try as well.
|
|
55
70
|
execute(method, sql, params = []) {
|
|
56
71
|
this.ensureConnected()
|
|
72
|
+
const timed = hasQueryListeners()
|
|
73
|
+
const start = timed ? performance.now() : 0
|
|
57
74
|
try {
|
|
58
|
-
|
|
75
|
+
const result = this.statement(sql)[method](...params)
|
|
76
|
+
if (timed) emitQuery({ driver: 'sqlite', sql, durationMs: performance.now() - start })
|
|
77
|
+
return result
|
|
59
78
|
} catch (err) {
|
|
79
|
+
if (timed) emitQuery({ driver: 'sqlite', sql, durationMs: performance.now() - start, error: err })
|
|
60
80
|
throw normalizeDatabaseError('sqlite', err, { sql, params })
|
|
61
81
|
}
|
|
62
82
|
}
|
|
63
83
|
|
|
84
|
+
statement(sql) {
|
|
85
|
+
if (DDL.test(sql)) {
|
|
86
|
+
this.statements.clear()
|
|
87
|
+
this.schemaCheckedAt = 0
|
|
88
|
+
return this.db.prepare(sql)
|
|
89
|
+
}
|
|
90
|
+
const now = Date.now()
|
|
91
|
+
if (now - this.schemaCheckedAt > SCHEMA_CHECK_MS) {
|
|
92
|
+
this.schemaCheckedAt = now
|
|
93
|
+
const { schema_version: version } = this.db.prepare('PRAGMA schema_version').get()
|
|
94
|
+
if (version !== this.schemaVersion) {
|
|
95
|
+
// Reading the schema table makes SQLite reload this connection's
|
|
96
|
+
// copy of the schema; reading the version alone does not, and new
|
|
97
|
+
// statements would still be prepared against the old columns.
|
|
98
|
+
if (this.schemaVersion !== null) this.db.prepare('SELECT 1 FROM sqlite_schema LIMIT 1').get()
|
|
99
|
+
this.statements.clear()
|
|
100
|
+
this.schemaVersion = version
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let statement = this.statements.get(sql)
|
|
104
|
+
if (statement) {
|
|
105
|
+
// Most recently used goes last, so the first entry is the oldest.
|
|
106
|
+
this.statements.delete(sql)
|
|
107
|
+
} else {
|
|
108
|
+
statement = this.db.prepare(sql)
|
|
109
|
+
if (this.statements.size >= STATEMENT_CACHE_SIZE) {
|
|
110
|
+
this.statements.delete(this.statements.keys().next().value)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
this.statements.set(sql, statement)
|
|
114
|
+
return statement
|
|
115
|
+
}
|
|
116
|
+
|
|
64
117
|
async run(sql, params = []) {
|
|
65
118
|
return this.execute('run', sql, params)
|
|
66
119
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -3,9 +3,11 @@ export * from "./adapters/SqliteAdapter.js";
|
|
|
3
3
|
export * from "./adapters/PostgresAdapter.js";
|
|
4
4
|
export * from "./model/ConnectionManager.js";
|
|
5
5
|
export * from "./model/Model.js";
|
|
6
|
+
export * from "./model/traits.js";
|
|
7
|
+
export declare const OBSERVER_EVENTS: readonly ["creating", "created", "updating", "updated", "deleting", "deleted"];
|
|
6
8
|
export * from "./adapters/AdapterFactory.js";
|
|
7
9
|
export * from "./adapters/MysqlAdapter.js";
|
|
8
10
|
export * from "./adapters/MongoAdapter.js";
|
|
9
11
|
export * from "./query/QueryBuilder.js";
|
|
10
12
|
export * from "./query/buildSelectSql.js";
|
|
11
|
-
export * from "./query/types.js";
|
|
13
|
+
export * from "./query/types.js";export * from "./instrumentation.js";
|
package/src/index.js
CHANGED
|
@@ -3,9 +3,12 @@ export * from './adapters/SqliteAdapter.js'
|
|
|
3
3
|
export * from './adapters/PostgresAdapter.js'
|
|
4
4
|
export * from './model/ConnectionManager.js'
|
|
5
5
|
export * from './model/Model.js'
|
|
6
|
+
export * from './model/traits.js'
|
|
7
|
+
export { OBSERVER_EVENTS } from './model/observers.js'
|
|
6
8
|
export * from './adapters/AdapterFactory.js'
|
|
7
9
|
export * from './adapters/MysqlAdapter.js'
|
|
8
10
|
export * from './adapters/MongoAdapter.js'
|
|
9
11
|
export * from './query/QueryBuilder.js'
|
|
10
12
|
export * from './query/buildSelectSql.js'
|
|
11
13
|
export * from './ensureDatabaseExists.js'
|
|
14
|
+
export * from './instrumentation.js'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface QueryEvent {
|
|
2
|
+
driver: string
|
|
3
|
+
sql: string
|
|
4
|
+
durationMs: number
|
|
5
|
+
error?: unknown
|
|
6
|
+
}
|
|
7
|
+
/** Called after every SQL statement; returns a function that removes the listener. */
|
|
8
|
+
export function onQuery(listener: (event: QueryEvent) => void): () => void
|
|
9
|
+
export function hasQueryListeners(): boolean
|
|
10
|
+
export function emitQuery(event: QueryEvent): void
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Query timing for logs and metrics. Every SQL adapter reports each
|
|
2
|
+
// statement it runs, with its duration, to the listeners registered here:
|
|
3
|
+
//
|
|
4
|
+
// const stop = onQuery(({ driver, sql, durationMs, error }) => { ... })
|
|
5
|
+
//
|
|
6
|
+
// With no listeners nothing is timed, so this costs nothing by default.
|
|
7
|
+
const listeners = new Set()
|
|
8
|
+
|
|
9
|
+
export function onQuery(listener) {
|
|
10
|
+
listeners.add(listener)
|
|
11
|
+
return () => listeners.delete(listener)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hasQueryListeners() {
|
|
15
|
+
return listeners.size > 0
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function emitQuery(event) {
|
|
19
|
+
for (const listener of listeners) {
|
|
20
|
+
try {
|
|
21
|
+
listener(event)
|
|
22
|
+
} catch {
|
|
23
|
+
// A broken listener must never fail the query.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/model/Model.d.ts
CHANGED
|
@@ -20,10 +20,29 @@ interface PaginateResult<T> {
|
|
|
20
20
|
|
|
21
21
|
type Row = Record<string, unknown>;
|
|
22
22
|
|
|
23
|
+
/** Hooks an observer may define; see observers.js. All optional. */
|
|
24
|
+
export interface Observer {
|
|
25
|
+
creating?(data: Row, model: typeof Model): Row | void | Promise<Row | void>;
|
|
26
|
+
created?(row: Row, model: typeof Model): unknown;
|
|
27
|
+
updating?(data: Row, before: Row | null, model: typeof Model): Row | void | Promise<Row | void>;
|
|
28
|
+
updated?(row: Row, before: Row | null, model: typeof Model): unknown;
|
|
29
|
+
deleting?(row: Row, model: typeof Model): unknown;
|
|
30
|
+
deleted?(row: Row, model: typeof Model): unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An observer object, or a class whose instances are observers. */
|
|
34
|
+
export type ObserverLike = Observer | (new () => Observer);
|
|
35
|
+
|
|
23
36
|
export declare class Model {
|
|
37
|
+
/** Lets traits extend models as TypeScript mixins (see traits.d.ts). */
|
|
38
|
+
constructor(...args: any[]);
|
|
24
39
|
static table: string | null;
|
|
25
40
|
static fillable: string[];
|
|
26
41
|
static timestamps: boolean;
|
|
42
|
+
/** Observers whose hooks run on create/update/delete. */
|
|
43
|
+
static observers?: ObserverLike[];
|
|
44
|
+
/** Adds observers at runtime. */
|
|
45
|
+
static observe<T extends typeof Model>(this: T, ...observers: ObserverLike[]): T;
|
|
27
46
|
|
|
28
47
|
static getTable(): string;
|
|
29
48
|
static getAdapter(): unknown;
|
|
@@ -43,6 +62,8 @@ export declare class Model {
|
|
|
43
62
|
static findOrFail(id: number | string): Promise<Row>;
|
|
44
63
|
static create(data: Row): Promise<Row | null>;
|
|
45
64
|
static update(id: number | string, data: Row): Promise<Row | null>;
|
|
65
|
+
/** Updates every row matching `where` in one statement; returns how many changed. Observers don't run. */
|
|
66
|
+
static updateWhere(where: Record<string, unknown>, values: Record<string, unknown>): Promise<number>;
|
|
46
67
|
static delete(id: number | string): Promise<boolean>;
|
|
47
68
|
static destroy(...ids: Array<number | string | Array<number | string>>): Promise<number>;
|
|
48
69
|
|
package/src/model/Model.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ConnectionManager } from './ConnectionManager.js'
|
|
2
2
|
import { QueryBuilder } from '../query/QueryBuilder.js'
|
|
3
|
+
import { addObservers, fire, hasHook, observersOf } from './observers.js'
|
|
3
4
|
|
|
4
5
|
export class Model {
|
|
5
6
|
static table = null
|
|
@@ -17,6 +18,16 @@ export class Model {
|
|
|
17
18
|
return ConnectionManager.getAdapter()
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Adds observers at runtime (see observers.js for the hooks):
|
|
23
|
+
* User.observe(UserObserver)
|
|
24
|
+
* Usually they're declared on the model instead: static observers = [...]
|
|
25
|
+
*/
|
|
26
|
+
static observe(...observers) {
|
|
27
|
+
addObservers(this, observers)
|
|
28
|
+
return this
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
static async all() {
|
|
21
32
|
const adapter = ConnectionManager.getAdapter()
|
|
22
33
|
return adapter.all(`SELECT * FROM ${this.getTable()}`)
|
|
@@ -110,8 +121,10 @@ export class Model {
|
|
|
110
121
|
return row
|
|
111
122
|
}
|
|
112
123
|
|
|
113
|
-
static async create(
|
|
124
|
+
static async create(input) {
|
|
114
125
|
const adapter = ConnectionManager.getAdapter()
|
|
126
|
+
const observers = observersOf(this)
|
|
127
|
+
const data = await fire(this, observers, 'creating', { ...input })
|
|
115
128
|
const fields = this.fillable.filter((f) => f in data)
|
|
116
129
|
|
|
117
130
|
if (fields.length === 0) {
|
|
@@ -135,11 +148,17 @@ export class Model {
|
|
|
135
148
|
columnValues,
|
|
136
149
|
)
|
|
137
150
|
|
|
138
|
-
|
|
151
|
+
const row = await this.find(result.lastInsertRowid)
|
|
152
|
+
await fire(this, observers, 'created', row)
|
|
153
|
+
return row
|
|
139
154
|
}
|
|
140
155
|
|
|
141
|
-
static async update(id,
|
|
156
|
+
static async update(id, input) {
|
|
142
157
|
const adapter = ConnectionManager.getAdapter()
|
|
158
|
+
const observers = observersOf(this)
|
|
159
|
+
// The row as it was, for updating/updated hooks; only read when needed.
|
|
160
|
+
const before = hasHook(observers, ['updating', 'updated']) ? await this.find(id) : null
|
|
161
|
+
const data = await fire(this, observers, 'updating', { ...input }, before)
|
|
143
162
|
const fields = this.fillable.filter((f) => f in data)
|
|
144
163
|
|
|
145
164
|
if (fields.length === 0) {
|
|
@@ -162,12 +181,28 @@ export class Model {
|
|
|
162
181
|
[...columnValues, id],
|
|
163
182
|
)
|
|
164
183
|
|
|
165
|
-
|
|
184
|
+
const row = await this.find(id)
|
|
185
|
+
await fire(this, observers, 'updated', row, before)
|
|
186
|
+
return row
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Updates every row matching `where` in one statement and returns how
|
|
190
|
+
// many changed. Bulk: observers don't run. Useful for check-and-set:
|
|
191
|
+
// const claimed = (await Job.updateWhere({ id, status: 'pending' }, { status: 'processing' })) === 1
|
|
192
|
+
static async updateWhere(where, values) {
|
|
193
|
+
const data = { ...values }
|
|
194
|
+
if (this.timestamps) data.updated_at = new Date().toISOString()
|
|
195
|
+
return ConnectionManager.getAdapter().updateWhere(this.getTable(), data, where)
|
|
166
196
|
}
|
|
167
197
|
|
|
168
198
|
static async delete(id) {
|
|
169
199
|
const adapter = ConnectionManager.getAdapter()
|
|
200
|
+
const observers = observersOf(this)
|
|
201
|
+
// deleting/deleted hooks get the row; a missing row runs no hooks.
|
|
202
|
+
const row = hasHook(observers, ['deleting', 'deleted']) ? await this.find(id) : null
|
|
203
|
+
if (row) await fire(this, observers, 'deleting', row)
|
|
170
204
|
await adapter.run(`DELETE FROM ${this.getTable()} WHERE id = ?`, [id])
|
|
205
|
+
if (row) await fire(this, observers, 'deleted', row)
|
|
171
206
|
return true
|
|
172
207
|
}
|
|
173
208
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Model observers: code that runs when rows are created, updated or deleted.
|
|
2
|
+
//
|
|
3
|
+
// class UserObserver {
|
|
4
|
+
// async created(user) { await dispatch('SendWelcomeEmail', { userId: user.id }) }
|
|
5
|
+
// }
|
|
6
|
+
// class User extends Model {
|
|
7
|
+
// static observers = [UserObserver]
|
|
8
|
+
// }
|
|
9
|
+
//
|
|
10
|
+
// Hooks (all optional, may be async):
|
|
11
|
+
// creating(data) before an insert; return an object to replace
|
|
12
|
+
// the data, or throw to cancel
|
|
13
|
+
// created(row) after the insert
|
|
14
|
+
// updating(data, before) before an update (before = current row);
|
|
15
|
+
// return an object to replace the data, or throw
|
|
16
|
+
// updated(row, before) after the update
|
|
17
|
+
// deleting(row) before a delete; throw to cancel
|
|
18
|
+
// deleted(row) after the delete
|
|
19
|
+
//
|
|
20
|
+
// Every hook also gets the model class as its last argument.
|
|
21
|
+
//
|
|
22
|
+
// Observers come from `static observers` on the model and on every class it
|
|
23
|
+
// extends (so traits can bring their own), plus Model.observe(...) calls.
|
|
24
|
+
// An observer is a class (created once and reused) or a plain object.
|
|
25
|
+
|
|
26
|
+
export const OBSERVER_EVENTS = ['creating', 'created', 'updating', 'updated', 'deleting', 'deleted']
|
|
27
|
+
|
|
28
|
+
// Model class -> observers added at runtime with Model.observe().
|
|
29
|
+
const registered = new WeakMap()
|
|
30
|
+
// Observer class -> its single instance.
|
|
31
|
+
const instances = new WeakMap()
|
|
32
|
+
|
|
33
|
+
function instantiate(observer) {
|
|
34
|
+
if (typeof observer === 'function') {
|
|
35
|
+
if (!instances.has(observer)) instances.set(observer, new observer())
|
|
36
|
+
return instances.get(observer)
|
|
37
|
+
}
|
|
38
|
+
if (observer && typeof observer === 'object') return observer
|
|
39
|
+
throw new TypeError('An observer must be a class or an object with hook methods.')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function addObservers(Model, observers) {
|
|
43
|
+
for (const observer of observers) instantiate(observer) // validate early
|
|
44
|
+
registered.set(Model, [...(registered.get(Model) ?? []), ...observers])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Every observer for a model: its parents' first, then its own.
|
|
48
|
+
export function observersOf(Model) {
|
|
49
|
+
const chain = []
|
|
50
|
+
for (let current = Model; current && current !== Function.prototype; current = Object.getPrototypeOf(current)) {
|
|
51
|
+
chain.unshift(current)
|
|
52
|
+
}
|
|
53
|
+
const list = []
|
|
54
|
+
for (const cls of chain) {
|
|
55
|
+
if (Object.hasOwn(cls, 'observers')) {
|
|
56
|
+
if (!Array.isArray(cls.observers)) {
|
|
57
|
+
throw new TypeError(`${cls.name}.observers must be an array of observers.`)
|
|
58
|
+
}
|
|
59
|
+
list.push(...cls.observers)
|
|
60
|
+
}
|
|
61
|
+
list.push(...(registered.get(cls) ?? []))
|
|
62
|
+
}
|
|
63
|
+
return list.map(instantiate)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Runs one event's hooks in order. For "creating"/"updating", a hook may
|
|
67
|
+
// return a new data object, which the next hook (and the write) receives.
|
|
68
|
+
export async function fire(Model, observers, event, data, ...rest) {
|
|
69
|
+
let current = data
|
|
70
|
+
for (const observer of observers) {
|
|
71
|
+
const hook = observer[event]
|
|
72
|
+
if (typeof hook !== 'function') continue
|
|
73
|
+
const result = await hook.call(observer, current, ...rest, Model)
|
|
74
|
+
if ((event === 'creating' || event === 'updating') && result && typeof result === 'object') {
|
|
75
|
+
current = result
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return current
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function hasHook(observers, events) {
|
|
82
|
+
return observers.some((observer) => events.some((event) => typeof observer[event] === 'function'))
|
|
83
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Model } from './Model.js';
|
|
2
|
+
|
|
3
|
+
type Row = Record<string, unknown>;
|
|
4
|
+
type ModelClass = typeof Model;
|
|
5
|
+
|
|
6
|
+
/** A trait: takes a model class, returns a class extending it with more. */
|
|
7
|
+
export type Trait<Added = {}> = <T extends ModelClass>(Base: T) => T & Added;
|
|
8
|
+
|
|
9
|
+
// Any trait function: the class it returns is what use() adds.
|
|
10
|
+
type AnyTrait = (Base: any) => any;
|
|
11
|
+
|
|
12
|
+
/** Applies traits to a base model class, left to right. */
|
|
13
|
+
export function use<T extends ModelClass>(Base: T): T;
|
|
14
|
+
export function use<T extends ModelClass, A extends AnyTrait>(Base: T, a: A): T & ReturnType<A>;
|
|
15
|
+
export function use<T extends ModelClass, A extends AnyTrait, B extends AnyTrait>(Base: T, a: A, b: B): T & ReturnType<A> & ReturnType<B>;
|
|
16
|
+
export function use<T extends ModelClass, A extends AnyTrait, B extends AnyTrait, C extends AnyTrait>(
|
|
17
|
+
Base: T, a: A, b: B, c: C,
|
|
18
|
+
): T & ReturnType<A> & ReturnType<B> & ReturnType<C>;
|
|
19
|
+
export function use<T extends ModelClass>(Base: T, ...traits: AnyTrait[]): T;
|
|
20
|
+
|
|
21
|
+
export interface SearchableStatics {
|
|
22
|
+
/** Rows where any of the trait's fields contains `term` (case-insensitive). */
|
|
23
|
+
search(term: string, options?: { limit?: number }): Promise<Row[]>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Adds Model.search(term) over the given columns. */
|
|
27
|
+
export function Searchable(options: { fields: string[] }): Trait<SearchableStatics>;
|
|
28
|
+
|
|
29
|
+
// Only what is exported above is public (helper types stay private).
|
|
30
|
+
export {};
|