@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.
@@ -0,0 +1,204 @@
1
+ import type { PoolOptions } from '../pool'
2
+ import type { SQLAdapter } from './base'
3
+
4
+ /**
5
+ * Every driver name the ORM knows, as an interface so a third-party adapter can
6
+ * add its own.
7
+ *
8
+ * A union type cannot be extended from outside the package, and widening it to
9
+ * `string` would give up every place the compiler currently catches a typo — so
10
+ * this is an interface and a new name arrives by declaration merging, the same
11
+ * mechanism `DBSchema` already uses for an app's tables:
12
+ *
13
+ * ```ts no-check — a third-party package's own declaration file
14
+ * declare module '@bakery-framework/orm/adapters' {
15
+ * interface DriverRegistry {
16
+ * mssql: true
17
+ * }
18
+ * }
19
+ * ```
20
+ *
21
+ * `@bakery-framework/orm/adapters` — the public subpath — and not this file, which is
22
+ * private and does not resolve from outside the package. The merge reaches
23
+ * through the barrel's `export *`; aiming it at an unresolvable specifier
24
+ * instead declares a second, unrelated interface and leaves every driver name
25
+ * rejected with nothing pointing at the cause.
26
+ *
27
+ * The value type is `true` and carries nothing: only the *keys* are read. It
28
+ * is a set spelled as an interface, because interfaces are what TypeScript
29
+ * lets you merge into.
30
+ */
31
+ export interface DriverRegistry {
32
+ sqlite: true
33
+ postgres: true
34
+ mysql: true
35
+ }
36
+
37
+ /** A registered driver name. Extend {@link DriverRegistry} to add one. */
38
+ export type Driver = keyof DriverRegistry & string
39
+
40
+ export interface AdapterSpec {
41
+ /**
42
+ * The driver name, which is also the registry key: registering twice under
43
+ * one name replaces the earlier entry rather than adding a second.
44
+ */
45
+ driver: Driver
46
+
47
+ /**
48
+ * URL schemes this adapter answers to, without `://`.
49
+ *
50
+ * Checked first and exactly — `mysql://…` reaches the adapter that listed
51
+ * `mysql`, and nothing else is consulted. List every spelling you accept;
52
+ * the built-in MySQL adapter lists four.
53
+ */
54
+ protocols?: readonly string[]
55
+
56
+ /**
57
+ * Last resort, for a target no scheme matched — a bare file path, say.
58
+ *
59
+ * Consulted in reverse registration order, so an adapter registered later can
60
+ * claim a target one of the built-ins would otherwise have taken. Return
61
+ * `false` (or omit the hook) to pass.
62
+ */
63
+ matches?(target: string): boolean
64
+
65
+ /**
66
+ * Open a connection. `target` is the raw `DB_URL`, or `undefined` when none
67
+ * was set and this adapter is the default.
68
+ *
69
+ * Async so the implementation can `await import()` its own driver module —
70
+ * which is how the three built-ins stay lazy: registering all of them costs
71
+ * three object literals, and only the one that wins is ever loaded.
72
+ */
73
+ open(
74
+ target: string | undefined,
75
+ pool: PoolOptions,
76
+ ): SQLAdapter | Promise<SQLAdapter>
77
+ }
78
+
79
+ /**
80
+ * What to fall back to when the target names no adapter — split in two because
81
+ * the two cases have never had the same answer. See {@link resolveDriver}.
82
+ */
83
+ export interface DriverFallback {
84
+ /** No `DB_URL` at all. */
85
+ empty: Driver
86
+ /** A target that matched no scheme and no `matches()` hook. */
87
+ unknown: Driver
88
+ }
89
+
90
+ export const DEFAULT_FALLBACK: DriverFallback = {
91
+ empty: 'sqlite',
92
+ unknown: 'postgres',
93
+ }
94
+
95
+ /**
96
+ * A stack per driver name, not one entry per name.
97
+ *
98
+ * Registering over an existing driver is temporary far more often than it is
99
+ * permanent — a test swapping in a fake, an app overriding a built-in for one
100
+ * environment — so "undo" has to give back exactly what was displaced. One
101
+ * entry per name cannot: release two registrations out of order and the second
102
+ * disposer restores the first's spec, quietly leaving a stub adapter installed
103
+ * for the rest of the process. A stack makes the order irrelevant, which is the
104
+ * only version that is safe to hand to a test.
105
+ *
106
+ * The Map's own order is first-registration order of each *name*, which is what
107
+ * `listAdapters` and the reverse scans in `resolveDriver` read.
108
+ */
109
+ const stacks = new Map<string, AdapterSpec[]>()
110
+
111
+ /**
112
+ * Add an adapter. An existing one under the same driver name is pushed down,
113
+ * not discarded.
114
+ *
115
+ * Returns a function that removes exactly this registration, whenever it is
116
+ * called and in any order relative to other disposers. A registry with no way
117
+ * out is a leak by construction.
118
+ */
119
+ export function registerAdapter(spec: AdapterSpec): () => void {
120
+ const stack = stacks.get(spec.driver) ?? []
121
+ stack.push(spec)
122
+ stacks.set(spec.driver, stack)
123
+ return () => {
124
+ const live = stacks.get(spec.driver)
125
+ if (!live) return
126
+ const at = live.lastIndexOf(spec)
127
+ if (at === -1) return
128
+ live.splice(at, 1)
129
+ if (!live.length) stacks.delete(spec.driver)
130
+ }
131
+ }
132
+
133
+ /** The registered adapter for a driver name, or `undefined`. */
134
+ export function getAdapter(driver: string): AdapterSpec | undefined {
135
+ return stacks.get(driver)?.at(-1)
136
+ }
137
+
138
+ /** Every registered adapter, one per driver, in registration order. */
139
+ export function listAdapters(): AdapterSpec[] {
140
+ return [...stacks.keys()].map(d => getAdapter(d)!).filter(Boolean)
141
+ }
142
+
143
+ /**
144
+ * Which adapter should open `target`.
145
+ *
146
+ * The order is the whole contract, so it is stated rather than left to be read
147
+ * out of the code:
148
+ *
149
+ * 1. **No target at all** → `fallback` (`sqlite`), so a fresh app with no
150
+ * `DB_URL` gets a file database rather than an error.
151
+ * 2. **A URL scheme** that some adapter declared → that adapter, exactly.
152
+ * 3. **`matches()`**, in reverse registration order — later registrations get
153
+ * first refusal, which is what makes overriding a built-in possible.
154
+ * 4. **`fallback`** otherwise, which for a target that looked like a host is
155
+ * `postgres`. Long-standing behaviour, kept deliberately: a bare
156
+ * `db.internal:5432/app` has been read as Postgres since before the
157
+ * registry existed.
158
+ *
159
+ * Throws only if the resolved driver has no adapter registered — which means
160
+ * something registered a `protocols` entry and then unregistered itself, not
161
+ * anything a user can reach by typing a URL.
162
+ */
163
+ export function resolveAdapter(
164
+ target: string,
165
+ fallback: DriverFallback = DEFAULT_FALLBACK,
166
+ ): AdapterSpec {
167
+ const driver = resolveDriver(target, fallback)
168
+ const spec = getAdapter(driver)
169
+ if (!spec) {
170
+ throw new Error(
171
+ `No adapter registered for driver '${driver}'. ` +
172
+ `Registered: ${[...stacks.keys()].join(', ') || '(none)'}.`,
173
+ )
174
+ }
175
+ return spec
176
+ }
177
+
178
+ /** Step 1–4 of {@link resolveAdapter}, without the lookup. */
179
+ export function resolveDriver(
180
+ target: string,
181
+ fallback: DriverFallback = DEFAULT_FALLBACK,
182
+ ): Driver {
183
+ const trimmed = target.trim()
184
+ if (!trimmed) return fallback.empty
185
+
186
+ // Newest first in both loops, so registering over a built-in is enough to
187
+ // take its protocols as well as its heuristics. Registration order alone
188
+ // would make an override depend on which module happened to load first.
189
+ const newestFirst = listAdapters().reverse()
190
+
191
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(trimmed)?.[1]?.toLowerCase()
192
+ if (scheme) {
193
+ for (const spec of newestFirst) {
194
+ if (spec.protocols?.some(p => p.toLowerCase() === scheme))
195
+ return spec.driver
196
+ }
197
+ }
198
+
199
+ for (const spec of newestFirst) {
200
+ if (spec.matches?.(trimmed)) return spec.driver
201
+ }
202
+
203
+ return fallback.unknown
204
+ }