@avelonjs/neon 0.3.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,263 @@
1
+ import { neon, Pool, type PoolClient } from '@neondatabase/serverless'
2
+ import {
3
+ Invalid,
4
+ type DatabaseDriver,
5
+ type DatabaseTransaction,
6
+ type MigrationPlan,
7
+ type MigrationStatus,
8
+ type QueryIR,
9
+ type QueryResult,
10
+ type TransactionSurface,
11
+ } from '@avelonjs/core'
12
+ import {
13
+ applyMigrations,
14
+ ASSAY_FIXTURE_STATEMENTS,
15
+ executeQueryIR,
16
+ executeRpc,
17
+ loadSchemaCache,
18
+ mapPostgresError,
19
+ planMigrations,
20
+ rollbackMigrations,
21
+ statusMigrations,
22
+ type PostgresMigration,
23
+ type SchemaCache,
24
+ type SqlBatchRunner,
25
+ type SqlRows,
26
+ type SqlRunner,
27
+ } from '@avelonjs/postgres/sql'
28
+
29
+ /** Exact capability declaration for the Neon database driver. */
30
+ export const neonDatabaseCapabilities = {
31
+ transactions: true,
32
+ rowSecurity: false,
33
+ /** Measured via nested application-side relation loads; not a PostgREST embed limit. */
34
+ maxRelationDepth: 8,
35
+ fullTextSearch: false,
36
+ upsert: true,
37
+ returning: true,
38
+ windowFunctions: true,
39
+ jsonOperators: true,
40
+ } as const
41
+
42
+ /** Construction options for {@link NeonDatabase}. */
43
+ export interface NeonDatabaseOptions {
44
+ /** Neon or Postgres connection URL. Defaults to `NEON_DATABASE_URL` or `DATABASE_URL`. */
45
+ url?: string
46
+ /** Configured connection name reported on the driver. */
47
+ instance?: string
48
+ /** Optional driver-owned migrations registered with this instance. */
49
+ migrations?: readonly PostgresMigration[]
50
+ }
51
+
52
+ /** Vendor client returned by {@link NeonDatabase.raw}. */
53
+ export type NeonSql = ReturnType<typeof neon>
54
+
55
+ interface VendorRows {
56
+ readonly rows: readonly Record<string, unknown>[]
57
+ readonly rowCount: number | null
58
+ }
59
+
60
+ function defaultUrl(): string {
61
+ return (
62
+ process.env.NEON_DATABASE_URL ??
63
+ process.env.DATABASE_URL ??
64
+ process.env.POSTGRES_URL ??
65
+ 'postgresql://postgres:avelon@127.0.0.1:5432/avelon_test'
66
+ )
67
+ }
68
+
69
+ function toSqlRows(result: VendorRows): SqlRows {
70
+ return {
71
+ rows: result.rows.map((row) => ({ ...row })),
72
+ count: result.rowCount ?? result.rows.length,
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Neon database driver.
78
+ *
79
+ * Query IR compilation, migrations, and schema checks come from `@avelonjs/postgres/sql`, which
80
+ * carries no `bun` specifier anywhere in its import graph, so this driver runs on Node. Reads,
81
+ * writes, RPC, and migrations go over Neon's HTTP endpoint. `transaction()` needs an interactive
82
+ * session, which HTTP cannot give, so it opens a WebSocket `Pool` connection on first use.
83
+ */
84
+ export class NeonDatabase
85
+ implements DatabaseDriver<typeof neonDatabaseCapabilities, NeonSql>, TransactionSurface
86
+ {
87
+ /** Driver implementation name. */
88
+ readonly name = 'neon'
89
+
90
+ /** Configured connection name. */
91
+ readonly instance: string
92
+
93
+ /** Exact optional-feature declaration. */
94
+ readonly capabilities = neonDatabaseCapabilities
95
+
96
+ readonly #url: string
97
+ readonly #sql: NeonSql
98
+ readonly #runner: SqlBatchRunner
99
+ readonly #migrations: readonly PostgresMigration[]
100
+ #pool: Pool | undefined
101
+ #schema: SchemaCache | undefined
102
+ #roundTrips = 0
103
+
104
+ /** Creates a driver bound to one Neon connection URL. */
105
+ constructor(options: NeonDatabaseOptions = {}) {
106
+ this.#url = options.url ?? defaultUrl()
107
+ this.instance = options.instance ?? 'default'
108
+ this.#migrations = options.migrations ?? []
109
+ this.#sql = neon(this.#url)
110
+ this.#runner = {
111
+ unsafe: async (text, parameters) =>
112
+ toSqlRows(
113
+ await this.#sql.query<false, true>(
114
+ text,
115
+ parameters === undefined ? [] : [...parameters],
116
+ {
117
+ fullResults: true,
118
+ },
119
+ ),
120
+ ),
121
+ batch: async (statements) => {
122
+ await this.#sql.transaction(
123
+ statements.map((statement) => this.#sql.query(statement.text, [...statement.parameters])),
124
+ )
125
+ },
126
+ }
127
+ }
128
+
129
+ /** Number of statements that reached Postgres. Used by conformance short-circuit checks. */
130
+ get roundTrips(): number {
131
+ return this.#roundTrips
132
+ }
133
+
134
+ /** Returns the `@neondatabase/serverless` query function. */
135
+ raw(): NeonSql {
136
+ return this.#sql
137
+ }
138
+
139
+ /** Drops and recreates the assay fixture schema used by live conformance. */
140
+ async resetFixtures(): Promise<void> {
141
+ for (const statement of ASSAY_FIXTURE_STATEMENTS) await this.#runner.unsafe(statement)
142
+ this.#schema = undefined
143
+ }
144
+
145
+ /** Closes the WebSocket pool when `transaction()` opened one. HTTP queries hold nothing. */
146
+ async close(): Promise<void> {
147
+ const pool = this.#pool
148
+ this.#pool = undefined
149
+ if (pool !== undefined) await pool.end()
150
+ }
151
+
152
+ /** Compiles and executes one query IR operation. */
153
+ async execute<TRow = Record<string, unknown>>(query: QueryIR): Promise<QueryResult<TRow>> {
154
+ return this.#execute(this.#runner, query) as Promise<QueryResult<TRow>>
155
+ }
156
+
157
+ /** Invokes a named Postgres routine. Missing routines map to `Invalid`. */
158
+ async rpc<TResult = unknown>(
159
+ routine: string,
160
+ args: Readonly<Record<string, unknown>>,
161
+ ): Promise<TResult> {
162
+ return executeRpc(this.#runner, routine, args, this.#countRoundTrip) as Promise<TResult>
163
+ }
164
+
165
+ /** Produces the pending migration plan. */
166
+ async plan(): Promise<MigrationPlan> {
167
+ return planMigrations(this.#runner, this.#migrations)
168
+ }
169
+
170
+ /** Applies pending migrations. */
171
+ async apply(): Promise<readonly MigrationStatus[]> {
172
+ return applyMigrations(this.#runner, this.#migrations)
173
+ }
174
+
175
+ /** Rolls back applied migration batches. */
176
+ async rollback(steps?: number): Promise<readonly MigrationStatus[]> {
177
+ return rollbackMigrations(this.#runner, this.#migrations, steps)
178
+ }
179
+
180
+ /** Returns all migration states. */
181
+ async status(): Promise<readonly MigrationStatus[]> {
182
+ return statusMigrations(this.#runner, this.#migrations)
183
+ }
184
+
185
+ /**
186
+ * Runs a callback inside a Postgres transaction.
187
+ *
188
+ * The callback reads rows before deciding what to issue next, so this takes a session-backed
189
+ * WebSocket connection rather than Neon's HTTP endpoint, whose transactions are non-interactive.
190
+ */
191
+ async transaction<TResult>(
192
+ callback: (transaction: DatabaseTransaction) => Promise<TResult>,
193
+ ): Promise<TResult> {
194
+ const client = await this.#connect()
195
+ const runner: SqlRunner = {
196
+ unsafe: async (text, parameters) =>
197
+ toSqlRows(await client.query(text, parameters === undefined ? [] : [...parameters])),
198
+ }
199
+ try {
200
+ await client.query('BEGIN')
201
+ const result = await callback({
202
+ execute: <TRow = Record<string, unknown>>(query: QueryIR) =>
203
+ this.#execute(runner, query) as Promise<QueryResult<TRow>>,
204
+ rpc: <TResultRpc = unknown>(routine: string, args: Readonly<Record<string, unknown>>) =>
205
+ executeRpc(runner, routine, args, this.#countRoundTrip) as Promise<TResultRpc>,
206
+ })
207
+ await client.query('COMMIT')
208
+ return result
209
+ } catch (error) {
210
+ await client.query('ROLLBACK').catch(() => undefined)
211
+ if (
212
+ error instanceof Invalid ||
213
+ (error instanceof Error &&
214
+ (error.name === 'Conflict' ||
215
+ error.name === 'DriverFault' ||
216
+ error.name === 'Unavailable'))
217
+ ) {
218
+ throw error
219
+ }
220
+ mapPostgresError(error, 'transaction')
221
+ } finally {
222
+ client.release()
223
+ }
224
+ }
225
+
226
+ readonly #countRoundTrip = (): void => {
227
+ this.#roundTrips += 1
228
+ }
229
+
230
+ async #connect(): Promise<PoolClient> {
231
+ if (this.#pool === undefined) {
232
+ const pool = new Pool({ connectionString: this.#url })
233
+ // An idle pooled connection dropped by the server emits 'error'; unhandled, that ends the process.
234
+ pool.on('error', () => undefined)
235
+ this.#pool = pool
236
+ }
237
+ try {
238
+ return await this.#pool.connect()
239
+ } catch (error) {
240
+ mapPostgresError(error, 'transaction')
241
+ }
242
+ }
243
+
244
+ async #schemaCache(): Promise<SchemaCache> {
245
+ this.#schema ??= await loadSchemaCache(this.#runner)
246
+ return this.#schema
247
+ }
248
+
249
+ async #execute(runner: SqlRunner, ir: QueryIR): Promise<QueryResult> {
250
+ return executeQueryIR(
251
+ runner,
252
+ await this.#schemaCache(),
253
+ ir,
254
+ this.capabilities.maxRelationDepth,
255
+ this.#countRoundTrip,
256
+ )
257
+ }
258
+ }
259
+
260
+ /** Creates a Neon database driver from options or environment defaults. */
261
+ export function createNeonDatabase(options: NeonDatabaseOptions = {}): NeonDatabase {
262
+ return new NeonDatabase(options)
263
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ createNeonDatabase,
3
+ NeonDatabase,
4
+ neonDatabaseCapabilities,
5
+ type NeonDatabaseOptions,
6
+ type NeonSql,
7
+ } from './driver'
8
+ export type { PostgresMigration } from '@avelonjs/postgres/sql'
@@ -0,0 +1,54 @@
1
+ import type { RequestCookies } from '@avelonjs/core'
2
+ import type { NeonSession } from './types'
3
+
4
+ /** Default cookie name used when no project-specific session cookie is configured. */
5
+ export const DEFAULT_SESSION_COOKIE = 'neon-avelon-auth-token'
6
+
7
+ /** Reads and parses the session cookie, returning null when absent or malformed. */
8
+ export function readSessionCookie(cookies: RequestCookies, cookieName: string): NeonSession | null {
9
+ const raw = cookies.get(cookieName)
10
+ if (raw === undefined || raw.length === 0) return null
11
+ try {
12
+ const parsed = JSON.parse(raw) as Partial<NeonSession> & { expiresAt?: string }
13
+ if (
14
+ typeof parsed.id !== 'string' ||
15
+ typeof parsed.accessToken !== 'string' ||
16
+ typeof parsed.refreshToken !== 'string' ||
17
+ typeof parsed.expiresAt !== 'string'
18
+ ) {
19
+ return null
20
+ }
21
+ const expiresAt = new Date(parsed.expiresAt)
22
+ if (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() <= Date.now()) return null
23
+ return {
24
+ id: parsed.id,
25
+ accessToken: parsed.accessToken,
26
+ refreshToken: parsed.refreshToken,
27
+ expiresAt,
28
+ }
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ /** Writes the session cookie for the current request scope. */
35
+ export function writeSessionCookie(
36
+ cookies: RequestCookies,
37
+ cookieName: string,
38
+ session: NeonSession,
39
+ ): void {
40
+ cookies.set(
41
+ cookieName,
42
+ JSON.stringify({
43
+ id: session.id,
44
+ accessToken: session.accessToken,
45
+ refreshToken: session.refreshToken,
46
+ expiresAt: session.expiresAt.toISOString(),
47
+ }),
48
+ )
49
+ }
50
+
51
+ /** Clears the session cookie for the current request scope. */
52
+ export function clearSessionCookie(cookies: RequestCookies, cookieName: string): void {
53
+ cookies.delete(cookieName)
54
+ }