@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.
- package/LICENSE +21 -0
- package/README.md +351 -0
- package/package.json +56 -0
- package/src/database/driver.ts +263 -0
- package/src/database/index.ts +8 -0
- package/src/identity/cookies.ts +54 -0
- package/src/identity/driver.ts +462 -0
- package/src/identity/errors.ts +206 -0
- package/src/identity/index.ts +15 -0
- package/src/identity/local-auth.ts +304 -0
- package/src/identity/types.ts +19 -0
- package/src/index.ts +6 -0
- package/src/queue/driver.ts +271 -0
- package/src/queue/index.ts +1 -0
- package/src/social/driver.ts +118 -0
- package/src/social/index.ts +8 -0
- package/src/social/local-auth.ts +55 -0
- package/src/social/types.ts +7 -0
- package/src/storage/driver.ts +182 -0
- package/src/storage/index.ts +7 -0
- package/src/storage/local-s3.ts +128 -0
- package/src/tokens/driver.ts +220 -0
- package/src/tokens/index.ts +6 -0
|
@@ -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,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
|
+
}
|