@bakery-framework/orm 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 +19 -0
- package/README.md +88 -0
- package/package.json +59 -0
- package/src/adapters/base.ts +1128 -0
- package/src/adapters/mysql.ts +619 -0
- package/src/adapters/observe.ts +261 -0
- package/src/adapters/pgsql.ts +611 -0
- package/src/adapters/registry.ts +204 -0
- package/src/adapters/sqlite.ts +588 -0
- package/src/adapters.ts +72 -0
- package/src/backup.ts +37 -0
- package/src/connection.ts +69 -0
- package/src/define.ts +380 -0
- package/src/field.ts +595 -0
- package/src/globals.d.ts +22 -0
- package/src/index.ts +63 -0
- package/src/orm/index.ts +24 -0
- package/src/orm/mutation.ts +692 -0
- package/src/orm/query.ts +1680 -0
- package/src/pool.ts +83 -0
- package/src/schema-registry.ts +75 -0
- package/src/schema-util.ts +467 -0
- package/src/sync/builder.ts +618 -0
- package/src/sync/engine.ts +399 -0
- package/src/sync/helpers.ts +1119 -0
- package/src/sync/history.ts +169 -0
- package/src/sync/index.ts +113 -0
- package/src/sync/ledger.ts +335 -0
- package/src/sync/load.ts +368 -0
- package/src/sync/rollback.ts +200 -0
- package/src/sync/types.ts +101 -0
- package/src/sync/view-sql.ts +160 -0
- package/templates/schema.example.ts +94 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonicalising a view's `SELECT` so the two sides of the diff can be compared.
|
|
3
|
+
*
|
|
4
|
+
* A view body is compared as **text** — there is no parser here, and there will
|
|
5
|
+
* not be one. That works only if both sides are spelled the same way, and they
|
|
6
|
+
* are not: you write
|
|
7
|
+
*
|
|
8
|
+
* SELECT id, name FROM users WHERE active = 1
|
|
9
|
+
*
|
|
10
|
+
* and MySQL hands back
|
|
11
|
+
*
|
|
12
|
+
* select `p`.`id` AS `id`,`p`.`name` AS `name` from `buzzy`.`users` `p` ...
|
|
13
|
+
*
|
|
14
|
+
* fully qualified, fully quoted, with every alias spelled out. Compare those
|
|
15
|
+
* literally and the view is recreated on every single sync, forever — the same
|
|
16
|
+
* perpetual-churn failure the column diff has hit twice.
|
|
17
|
+
*
|
|
18
|
+
* So everything here must hold two properties, and both are tested:
|
|
19
|
+
*
|
|
20
|
+
* - **Idempotent**: `f(f(x)) === f(x)`.
|
|
21
|
+
* - **Convergent**: `f(what you wrote) === f(what the server returns)`.
|
|
22
|
+
*
|
|
23
|
+
* Which is why this only removes *noise* — quoting, schema qualification,
|
|
24
|
+
* redundant aliases, whitespace. It never reorders, rewrites or reflows
|
|
25
|
+
* anything semantic, because a transformation the server would not also produce
|
|
26
|
+
* is one that makes the two sides differ rather than agree.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Words that must keep their quoting: unquoting one turns an identifier into
|
|
31
|
+
* syntax. Deliberately small — it only has to cover words a column or table is
|
|
32
|
+
* plausibly *named* after, since anything else was never quoted to begin with.
|
|
33
|
+
*/
|
|
34
|
+
const RESERVED = new Set([
|
|
35
|
+
'SELECT',
|
|
36
|
+
'FROM',
|
|
37
|
+
'WHERE',
|
|
38
|
+
'JOIN',
|
|
39
|
+
'LEFT',
|
|
40
|
+
'RIGHT',
|
|
41
|
+
'INNER',
|
|
42
|
+
'OUTER',
|
|
43
|
+
'FULL',
|
|
44
|
+
'CROSS',
|
|
45
|
+
'ON',
|
|
46
|
+
'AS',
|
|
47
|
+
'AND',
|
|
48
|
+
'OR',
|
|
49
|
+
'NOT',
|
|
50
|
+
'NULL',
|
|
51
|
+
'IS',
|
|
52
|
+
'IN',
|
|
53
|
+
'EXISTS',
|
|
54
|
+
'GROUP',
|
|
55
|
+
'BY',
|
|
56
|
+
'ORDER',
|
|
57
|
+
'HAVING',
|
|
58
|
+
'LIMIT',
|
|
59
|
+
'OFFSET',
|
|
60
|
+
'ASC',
|
|
61
|
+
'DESC',
|
|
62
|
+
'UNION',
|
|
63
|
+
'ALL',
|
|
64
|
+
'DISTINCT',
|
|
65
|
+
'CASE',
|
|
66
|
+
'WHEN',
|
|
67
|
+
'THEN',
|
|
68
|
+
'ELSE',
|
|
69
|
+
'END',
|
|
70
|
+
'CAST',
|
|
71
|
+
'BETWEEN',
|
|
72
|
+
'LIKE',
|
|
73
|
+
'WITH',
|
|
74
|
+
'RECURSIVE',
|
|
75
|
+
'USING',
|
|
76
|
+
'VALUES',
|
|
77
|
+
'INTERVAL',
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
const PLAIN_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Strip the quoting around a plain identifier, leaving anything else alone.
|
|
84
|
+
*
|
|
85
|
+
* Both dialect spellings, because the same schema is compared against whichever
|
|
86
|
+
* server it is on: MySQL returns backticks, Postgres double quotes.
|
|
87
|
+
*/
|
|
88
|
+
function unquoteIdentifiers(sql: string): string {
|
|
89
|
+
return sql.replace(/`([^`]+)`|"([^"]+)"/g, (whole, tick, dquote) => {
|
|
90
|
+
const word = tick ?? dquote
|
|
91
|
+
return PLAIN_IDENTIFIER.test(word) && !RESERVED.has(word.toUpperCase())
|
|
92
|
+
? word
|
|
93
|
+
: whole
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Drop `db.` from `db.table`, for the database the view lives in.
|
|
99
|
+
*
|
|
100
|
+
* MySQL qualifies every table in a stored view with the schema it was created
|
|
101
|
+
* in, so the body carries a hard-coded database name — and the same schema
|
|
102
|
+
* deployed against a differently-named database would then compare unequal and
|
|
103
|
+
* be recreated forever. It is also simply wrong to write down: the view already
|
|
104
|
+
* lives in that database.
|
|
105
|
+
*
|
|
106
|
+
* Only the view's *own* database is stripped. A genuine cross-database
|
|
107
|
+
* reference keeps its qualifier, because there the name is load-bearing.
|
|
108
|
+
*/
|
|
109
|
+
function stripOwnSchema(sql: string, database?: string): string {
|
|
110
|
+
if (!database) return sql
|
|
111
|
+
const escaped = database.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
112
|
+
return sql.replace(new RegExp(`\\b${escaped}\\.(?=[a-zA-Z_\`"])`, 'g'), '')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `x AS x` is what MySQL writes for every selected column. It says nothing, and
|
|
117
|
+
* you would not have typed it.
|
|
118
|
+
*
|
|
119
|
+
* Matched on the *last* segment so `p.id AS id` collapses too — the qualifier
|
|
120
|
+
* is part of where the value comes from, not of what the output column is
|
|
121
|
+
* called.
|
|
122
|
+
*/
|
|
123
|
+
function dropRedundantAliases(sql: string): string {
|
|
124
|
+
return sql.replace(
|
|
125
|
+
/([a-zA-Z_][a-zA-Z0-9_]*)\s+AS\s+([a-zA-Z_][a-zA-Z0-9_]*)/gi,
|
|
126
|
+
(whole, expr, alias) => (expr === alias ? expr : whole),
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The canonical form of a view body, for comparison and for writing down.
|
|
132
|
+
*
|
|
133
|
+
* `database` is the schema the view belongs to; pass it and the qualifier goes.
|
|
134
|
+
*/
|
|
135
|
+
export function normalizeViewBody(sql: string, database?: string): string {
|
|
136
|
+
if (!sql) return ''
|
|
137
|
+
let out = String(sql).replace(/\s+/g, ' ').trim()
|
|
138
|
+
out = unquoteIdentifiers(out)
|
|
139
|
+
out = stripOwnSchema(out, database)
|
|
140
|
+
out = dropRedundantAliases(out)
|
|
141
|
+
// Qualifier removal can leave `from products`; alias removal can leave a
|
|
142
|
+
// doubled space too. Collapse once more so the result is stable under a
|
|
143
|
+
// second pass — which is what makes this idempotent.
|
|
144
|
+
return out.replace(/\s+/g, ' ').trim()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The same body, broken across lines for a generated file.
|
|
149
|
+
*
|
|
150
|
+
* Safe only because the comparison collapses whitespace before comparing: this
|
|
151
|
+
* adds newlines and nothing else, so a normalised pretty body and a normalised
|
|
152
|
+
* canonical one are the same string.
|
|
153
|
+
*/
|
|
154
|
+
export function formatViewBody(sql: string, database?: string): string {
|
|
155
|
+
const normalized = normalizeViewBody(sql, database)
|
|
156
|
+
return normalized.replace(
|
|
157
|
+
/\s+(from|where|group by|order by|having|limit|left join|right join|inner join|cross join|join|union all|union)\s+/gi,
|
|
158
|
+
(_m, kw) => `\n ${String(kw).toLowerCase()} `,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template schema — copy this to `schema.ts` to get started:
|
|
3
|
+
*
|
|
4
|
+
* ```sh
|
|
5
|
+
* cp schema.example.ts schema.ts
|
|
6
|
+
* bun run db:sync
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* `schema.ts` itself is gitignored: it belongs to the application, and
|
|
10
|
+
* `db:sync` rewrites it (it can generate the file from an existing database
|
|
11
|
+
* with `--choose=db`). This example is tracked so the shape — and, more
|
|
12
|
+
* importantly, the registration block at the bottom — is discoverable in a
|
|
13
|
+
* fresh clone.
|
|
14
|
+
*
|
|
15
|
+
* The framework never imports this file for types. Everything runs and
|
|
16
|
+
* typechecks without it; the ORM is simply untyped (permissive `any`
|
|
17
|
+
* columns) until a schema registers itself.
|
|
18
|
+
*/
|
|
19
|
+
import { Field } from '@bakery-framework/orm'
|
|
20
|
+
import type {
|
|
21
|
+
ExtractOptionals,
|
|
22
|
+
ExtractTableTypes,
|
|
23
|
+
ExtractViews,
|
|
24
|
+
} from '@bakery-framework/orm/schema-util'
|
|
25
|
+
|
|
26
|
+
export namespace DBInfo {
|
|
27
|
+
export const constraints = {
|
|
28
|
+
users: {
|
|
29
|
+
id: Field.Primary(),
|
|
30
|
+
// `Varchar` rather than `Text` wherever a length is known: it is the only
|
|
31
|
+
// text form that can carry a default on MySQL, and the width is part of
|
|
32
|
+
// the column diff, so widening one migrates.
|
|
33
|
+
username: Field.Varchar(64, null),
|
|
34
|
+
email: Field.Varchar(255, null),
|
|
35
|
+
password: Field.Varchar(255, null),
|
|
36
|
+
createdAt: Field.Date.now(),
|
|
37
|
+
},
|
|
38
|
+
posts: {
|
|
39
|
+
id: Field.Primary(),
|
|
40
|
+
authorId: Field.Int(null),
|
|
41
|
+
title: Field.Varchar(255, null),
|
|
42
|
+
slug: Field.Varchar(255, null),
|
|
43
|
+
// Sized, not `Text`, because it has a default: MySQL refuses a literal
|
|
44
|
+
// DEFAULT on a TEXT column, so `Field.String('')` here is what previously
|
|
45
|
+
// stopped this template syncing against MySQL at all. Use `Field.Text()`
|
|
46
|
+
// for unbounded text with no default.
|
|
47
|
+
body: Field.Varchar(8192, ''),
|
|
48
|
+
published: Field.Int(0),
|
|
49
|
+
createdAt: Field.Date.now(),
|
|
50
|
+
},
|
|
51
|
+
comments: {
|
|
52
|
+
id: Field.Primary(),
|
|
53
|
+
postId: Field.Int(null),
|
|
54
|
+
authorId: Field.Int(null),
|
|
55
|
+
body: Field.Varchar(8192, ''),
|
|
56
|
+
createdAt: Field.Date.now(),
|
|
57
|
+
},
|
|
58
|
+
} as const
|
|
59
|
+
|
|
60
|
+
export const indexes = {
|
|
61
|
+
usersUsernameUniq: Field.Unique('users', ['username']),
|
|
62
|
+
postsSlugUniq: Field.Unique('posts', ['slug']),
|
|
63
|
+
postsAuthorIdx: Field.Index('posts', ['authorId']),
|
|
64
|
+
commentsPostIdx: Field.Index('comments', ['postId']),
|
|
65
|
+
} as const
|
|
66
|
+
|
|
67
|
+
type C = typeof constraints
|
|
68
|
+
export type Table<T extends keyof C> = ExtractTableTypes<C, T>
|
|
69
|
+
export type Optionals<T extends keyof C> = ExtractOptionals<C, T>
|
|
70
|
+
export type Views = ExtractViews<C>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type DBSchema = {
|
|
74
|
+
[T in keyof typeof DBInfo.constraints]: DBInfo.Table<T>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type DBOptionals = {
|
|
78
|
+
[T in keyof typeof DBInfo.constraints]: DBInfo.Optionals<T>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Registers this schema with the framework's type system, which is what makes
|
|
83
|
+
* `DB.from('posts')` know its columns. Keep this block — without it the ORM
|
|
84
|
+
* still works, but every table and column is `any`.
|
|
85
|
+
*/
|
|
86
|
+
declare module '@bakery-framework/orm/schema-registry' {
|
|
87
|
+
interface SchemaRegistry {
|
|
88
|
+
schema: {
|
|
89
|
+
DBSchema: DBSchema
|
|
90
|
+
DBOptionals: DBOptionals
|
|
91
|
+
Views: DBInfo.Views
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|