@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,304 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
interface LocalUser {
|
|
4
|
+
id: string
|
|
5
|
+
email: string
|
|
6
|
+
passwordHash: string
|
|
7
|
+
totpEnabled: boolean
|
|
8
|
+
emailVerified: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface LocalSession {
|
|
12
|
+
id: string
|
|
13
|
+
userId: string
|
|
14
|
+
token: string
|
|
15
|
+
expiresAt: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface LocalReset {
|
|
19
|
+
email: string
|
|
20
|
+
token: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface LocalVerification {
|
|
24
|
+
email: string
|
|
25
|
+
token: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hashPassword(password: string): string {
|
|
29
|
+
return createHash('sha256').update(password).digest('hex')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Minimal Better Auth-shaped HTTP server for live identity conformance.
|
|
34
|
+
*
|
|
35
|
+
* Real deployments point the same driver at Neon Auth. This server exists so cookie and password
|
|
36
|
+
* flows can be verified without a hosted Neon Auth project.
|
|
37
|
+
*/
|
|
38
|
+
export class LocalAuthServer {
|
|
39
|
+
readonly #users = new Map<string, LocalUser>()
|
|
40
|
+
readonly #sessions = new Map<string, LocalSession>()
|
|
41
|
+
readonly #resets = new Map<string, LocalReset>()
|
|
42
|
+
readonly #verifications = new Map<string, LocalVerification>()
|
|
43
|
+
readonly #magicLinks = new Map<string, string>()
|
|
44
|
+
#server: ReturnType<typeof Bun.serve> | undefined
|
|
45
|
+
#url = ''
|
|
46
|
+
|
|
47
|
+
/** Base Auth URL, e.g. `http://127.0.0.1:3000/api/auth`. */
|
|
48
|
+
get url(): string {
|
|
49
|
+
return this.#url
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Starts the server on an ephemeral port. */
|
|
53
|
+
async start(): Promise<string> {
|
|
54
|
+
const self = this
|
|
55
|
+
this.#server = Bun.serve({
|
|
56
|
+
port: 0,
|
|
57
|
+
async fetch(request) {
|
|
58
|
+
return self.#handle(request)
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
this.#url = `http://127.0.0.1:${this.#server.port}/api/auth`
|
|
62
|
+
return this.#url
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Clears users, sessions, reset tokens, and verification tokens without restarting the listener. */
|
|
66
|
+
reset(): void {
|
|
67
|
+
this.#users.clear()
|
|
68
|
+
this.#sessions.clear()
|
|
69
|
+
this.#resets.clear()
|
|
70
|
+
this.#verifications.clear()
|
|
71
|
+
this.#magicLinks.clear()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Stops the server and clears fixture state. */
|
|
75
|
+
async stop(): Promise<void> {
|
|
76
|
+
this.#server?.stop(true)
|
|
77
|
+
this.#server = undefined
|
|
78
|
+
this.reset()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Returns the magic-link token issued for an email, if any. */
|
|
82
|
+
magicLinkToken(email: string): string | undefined {
|
|
83
|
+
return [...this.#magicLinks.entries()].find(([, value]) => value === email)?.[0]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Returns the recovery token issued for an email, if any. */
|
|
87
|
+
recoveryToken(email: string): string | undefined {
|
|
88
|
+
return [...this.#resets.values()].find((entry) => entry.email === email)?.token
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Returns the email confirmation token issued for an email, if any. */
|
|
92
|
+
verificationToken(email: string): string | undefined {
|
|
93
|
+
return [...this.#verifications.values()].find((entry) => entry.email === email)?.token
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async #handle(request: Request): Promise<Response> {
|
|
97
|
+
const url = new URL(request.url)
|
|
98
|
+
const path = url.pathname.replace(/^\/api\/auth/, '')
|
|
99
|
+
|
|
100
|
+
if (request.method === 'POST' && path === '/sign-up/email') {
|
|
101
|
+
const body = (await request.json()) as { email?: string; password?: string }
|
|
102
|
+
if (!body.email || !body.password) {
|
|
103
|
+
return fail(400, 'INVALID_EMAIL', 'Invalid email')
|
|
104
|
+
}
|
|
105
|
+
if ([...this.#users.values()].some((user) => user.email === body.email)) {
|
|
106
|
+
return fail(422, 'USER_ALREADY_EXISTS', 'User already exists')
|
|
107
|
+
}
|
|
108
|
+
const user: LocalUser = {
|
|
109
|
+
id: randomUUID(),
|
|
110
|
+
email: body.email,
|
|
111
|
+
passwordHash: hashPassword(body.password),
|
|
112
|
+
totpEnabled: true,
|
|
113
|
+
emailVerified: false,
|
|
114
|
+
}
|
|
115
|
+
this.#users.set(user.id, user)
|
|
116
|
+
return json(this.#sessionPayload(user))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (request.method === 'POST' && path === '/sign-in/email') {
|
|
120
|
+
const body = (await request.json()) as { email?: string; password?: string }
|
|
121
|
+
const user = [...this.#users.values()].find((entry) => entry.email === body.email)
|
|
122
|
+
if (!user || user.passwordHash !== hashPassword(body.password ?? '')) {
|
|
123
|
+
return fail(401, 'INVALID_EMAIL_OR_PASSWORD', 'Invalid email or password')
|
|
124
|
+
}
|
|
125
|
+
return json(this.#sessionPayload(user))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (request.method === 'GET' && path === '/get-session') {
|
|
129
|
+
const session = this.#sessionFromAuth(request)
|
|
130
|
+
if (!session) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
|
|
131
|
+
const user = this.#users.get(session.userId)
|
|
132
|
+
if (!user) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
|
|
133
|
+
return json({
|
|
134
|
+
user: {
|
|
135
|
+
id: user.id,
|
|
136
|
+
email: user.email,
|
|
137
|
+
emailVerified: user.emailVerified,
|
|
138
|
+
twoFactorEnabled: user.totpEnabled,
|
|
139
|
+
},
|
|
140
|
+
session: {
|
|
141
|
+
id: session.id,
|
|
142
|
+
token: session.token,
|
|
143
|
+
expiresAt: new Date(session.expiresAt).toISOString(),
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (request.method === 'POST' && path === '/sign-out') {
|
|
149
|
+
const session = this.#sessionFromAuth(request)
|
|
150
|
+
if (!session) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
|
|
151
|
+
this.#sessions.delete(session.token)
|
|
152
|
+
return json({})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (request.method === 'POST' && path === '/change-password') {
|
|
156
|
+
const session = this.#sessionFromAuth(request)
|
|
157
|
+
if (!session) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
|
|
158
|
+
const user = this.#users.get(session.userId)
|
|
159
|
+
if (!user) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
|
|
160
|
+
const body = (await request.json()) as { newPassword?: string }
|
|
161
|
+
if (!body.newPassword) return fail(400, 'BAD_REQUEST', 'Missing required fields')
|
|
162
|
+
user.passwordHash = hashPassword(body.newPassword)
|
|
163
|
+
return json({ user: { id: user.id, email: user.email } })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (request.method === 'POST' && path === '/forget-password') {
|
|
167
|
+
const body = (await request.json()) as { email?: string }
|
|
168
|
+
if (body.email) {
|
|
169
|
+
const token = `assay-reset-${randomUUID()}`
|
|
170
|
+
this.#resets.set(token, { email: body.email, token })
|
|
171
|
+
}
|
|
172
|
+
return json({})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (request.method === 'POST' && path === '/reset-password') {
|
|
176
|
+
const body = (await request.json()) as { token?: string; newPassword?: string }
|
|
177
|
+
if (!body.token || !body.newPassword)
|
|
178
|
+
return fail(400, 'BAD_REQUEST', 'Missing required fields')
|
|
179
|
+
const reset = this.#resets.get(body.token)
|
|
180
|
+
if (!reset) return fail(400, 'INVALID_TOKEN', 'Invalid token')
|
|
181
|
+
const user = [...this.#users.values()].find((entry) => entry.email === reset.email)
|
|
182
|
+
if (!user) return fail(400, 'INVALID_TOKEN', 'Invalid token')
|
|
183
|
+
user.passwordHash = hashPassword(body.newPassword)
|
|
184
|
+
this.#resets.delete(body.token)
|
|
185
|
+
return json({ user: { id: user.id, email: user.email } })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (request.method === 'POST' && path === '/sign-in/magic-link') {
|
|
189
|
+
const body = (await request.json()) as { email?: string }
|
|
190
|
+
if (body.email) this.#magicLinks.set(`assay-magic-${randomUUID()}`, body.email)
|
|
191
|
+
return json({ status: true })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (request.method === 'GET' && path === '/magic-link/verify') {
|
|
195
|
+
const token = url.searchParams.get('token')
|
|
196
|
+
const email = token === null ? undefined : this.#magicLinks.get(token)
|
|
197
|
+
const user = email
|
|
198
|
+
? [...this.#users.values()].find((entry) => entry.email === email)
|
|
199
|
+
: undefined
|
|
200
|
+
if (token === null || !user) return fail(401, 'INVALID_TOKEN', 'Invalid token')
|
|
201
|
+
this.#magicLinks.delete(token)
|
|
202
|
+
return json(this.#sessionPayload(user))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (request.method === 'GET' && path === '/two-factor/status') {
|
|
206
|
+
const session = this.#sessionFromAuth(request)
|
|
207
|
+
if (!session) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
|
|
208
|
+
const user = this.#users.get(session.userId)
|
|
209
|
+
if (!user) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
|
|
210
|
+
return json({ enabled: user.totpEnabled })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (request.method === 'POST' && path === '/two-factor/verify-totp') {
|
|
214
|
+
const session = this.#sessionFromAuth(request)
|
|
215
|
+
if (!session) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
|
|
216
|
+
const user = this.#users.get(session.userId)
|
|
217
|
+
if (!user) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
|
|
218
|
+
const body = (await request.json()) as { code?: string }
|
|
219
|
+
if (!user.totpEnabled || body.code !== '123456') {
|
|
220
|
+
return fail(400, 'INVALID_TWO_FACTOR_CODE', 'Invalid two factor code')
|
|
221
|
+
}
|
|
222
|
+
return json({})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (request.method === 'POST' && path === '/send-verification-email') {
|
|
226
|
+
const body = (await request.json()) as { email?: string }
|
|
227
|
+
if (body.email && [...this.#users.values()].some((user) => user.email === body.email)) {
|
|
228
|
+
const token = `assay-verify-${randomUUID()}`
|
|
229
|
+
this.#verifications.set(token, { email: body.email, token })
|
|
230
|
+
}
|
|
231
|
+
return json({})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (request.method === 'GET' && path === '/verify-email') {
|
|
235
|
+
const token = url.searchParams.get('token')
|
|
236
|
+
if (!token) return fail(400, 'BAD_REQUEST', 'Missing required fields')
|
|
237
|
+
const verification = this.#verifications.get(token)
|
|
238
|
+
if (!verification) return fail(400, 'INVALID_TOKEN', 'Invalid token')
|
|
239
|
+
const user = [...this.#users.values()].find((entry) => entry.email === verification.email)
|
|
240
|
+
if (!user) return fail(400, 'INVALID_TOKEN', 'Invalid token')
|
|
241
|
+
user.emailVerified = true
|
|
242
|
+
this.#verifications.delete(token)
|
|
243
|
+
return json({ user: { id: user.id, email: user.email, emailVerified: true } })
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return fail(404, 'NOT_FOUND', 'Requested path is invalid')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#sessionPayload(user: LocalUser): AuthPayload {
|
|
250
|
+
const session = this.#createSession(user.id)
|
|
251
|
+
return {
|
|
252
|
+
user: { id: user.id, email: user.email },
|
|
253
|
+
token: session.token,
|
|
254
|
+
session: {
|
|
255
|
+
id: session.id,
|
|
256
|
+
token: session.token,
|
|
257
|
+
expiresAt: new Date(session.expiresAt).toISOString(),
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#createSession(userId: string): LocalSession {
|
|
263
|
+
const session: LocalSession = {
|
|
264
|
+
id: randomUUID(),
|
|
265
|
+
userId,
|
|
266
|
+
token: randomUUID(),
|
|
267
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
268
|
+
}
|
|
269
|
+
this.#sessions.set(session.token, session)
|
|
270
|
+
return session
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#sessionFromAuth(request: Request): LocalSession | undefined {
|
|
274
|
+
const header = request.headers.get('authorization')
|
|
275
|
+
if (header === null || !header.startsWith('Bearer ')) return undefined
|
|
276
|
+
const token = header.slice('Bearer '.length)
|
|
277
|
+
const session = this.#sessions.get(token)
|
|
278
|
+
if (session === undefined || session.expiresAt <= Date.now()) return undefined
|
|
279
|
+
return session
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
interface AuthPayload {
|
|
284
|
+
user: { id: string; email: string }
|
|
285
|
+
token: string
|
|
286
|
+
session: { id: string; token: string; expiresAt: string }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A Better Auth error envelope.
|
|
291
|
+
*
|
|
292
|
+
* Neon Auth answers a failure with a SCREAMING_SNAKE `code` and a `message` sentence. The driver
|
|
293
|
+
* classifies on that envelope, so this server emits it rather than a shape invented to match.
|
|
294
|
+
*/
|
|
295
|
+
function fail(status: number, code: string, message: string): Response {
|
|
296
|
+
return json({ code, message }, status)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function json(body: unknown, status = 200): Response {
|
|
300
|
+
return new Response(JSON.stringify(body), {
|
|
301
|
+
status,
|
|
302
|
+
headers: { 'Content-Type': 'application/json' },
|
|
303
|
+
})
|
|
304
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Actor returned by the Neon identity driver. */
|
|
2
|
+
export interface NeonActor {
|
|
3
|
+
/** Stable actor identifier. */
|
|
4
|
+
id: string
|
|
5
|
+
/** Sign-in email address. */
|
|
6
|
+
email: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Session returned by the Neon identity driver. */
|
|
10
|
+
export interface NeonSession {
|
|
11
|
+
/** Stable session identifier. */
|
|
12
|
+
id: string
|
|
13
|
+
/** Session token retained for subsequent Auth API calls. */
|
|
14
|
+
accessToken: string
|
|
15
|
+
/** Refresh token used for rotation when present. */
|
|
16
|
+
refreshToken: string
|
|
17
|
+
/** Time after which the session should be treated as expired. */
|
|
18
|
+
expiresAt: Date
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { SQL } from 'bun'
|
|
2
|
+
import type {
|
|
3
|
+
DeadLetterQueueSurface,
|
|
4
|
+
DelayedQueueSurface,
|
|
5
|
+
FailedQueueJob,
|
|
6
|
+
QueueDriver,
|
|
7
|
+
QueueJob,
|
|
8
|
+
QueueReceipt,
|
|
9
|
+
RetryQueueSurface,
|
|
10
|
+
} from '@avelonjs/core'
|
|
11
|
+
|
|
12
|
+
/** Exact capability declaration for the Neon queue driver. */
|
|
13
|
+
export const neonQueueCapabilities = {
|
|
14
|
+
delayed: true,
|
|
15
|
+
retries: true,
|
|
16
|
+
deadLetter: true,
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
/** Construction options for {@link createNeonQueue}. */
|
|
20
|
+
export interface NeonQueueOptions {
|
|
21
|
+
/** Neon or Postgres URL used to persist jobs. */
|
|
22
|
+
url?: string
|
|
23
|
+
/** Configured queue connection name. */
|
|
24
|
+
instance?: string
|
|
25
|
+
/** Attempts before a job is dead-lettered. */
|
|
26
|
+
maxTries?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface JobRow {
|
|
30
|
+
id: string
|
|
31
|
+
queue: string | null
|
|
32
|
+
name: string
|
|
33
|
+
payload: unknown
|
|
34
|
+
attempt: number
|
|
35
|
+
available_at: Date | string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface FailedRow extends JobRow {
|
|
39
|
+
error: string
|
|
40
|
+
failed_at: Date | string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultUrl(): string {
|
|
44
|
+
return (
|
|
45
|
+
process.env.NEON_DATABASE_URL ??
|
|
46
|
+
process.env.POSTGRES_URL ??
|
|
47
|
+
process.env.DATABASE_URL ??
|
|
48
|
+
'postgresql://postgres:avelon@127.0.0.1:5432/avelon_test'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function asDate(value: Date | string): Date {
|
|
53
|
+
return value instanceof Date ? value : new Date(value)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function failureMessage(error: unknown): string {
|
|
57
|
+
return error instanceof Error ? error.message : String(error)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toJob(row: JobRow): QueueJob {
|
|
61
|
+
return {
|
|
62
|
+
name: row.name,
|
|
63
|
+
payload: decodePayload(row.payload),
|
|
64
|
+
...(row.queue === null ? {} : { queue: row.queue }),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decodePayload(value: unknown): unknown {
|
|
69
|
+
if (typeof value !== 'string') return value
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(value) as unknown
|
|
72
|
+
} catch {
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function encodePayload(value: unknown): string {
|
|
78
|
+
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const SCHEMA_SQL = `
|
|
82
|
+
CREATE TABLE IF NOT EXISTS avelon_jobs (
|
|
83
|
+
id text PRIMARY KEY,
|
|
84
|
+
queue text,
|
|
85
|
+
name text NOT NULL,
|
|
86
|
+
payload jsonb NOT NULL,
|
|
87
|
+
attempt integer NOT NULL DEFAULT 0,
|
|
88
|
+
available_at timestamptz NOT NULL,
|
|
89
|
+
locked_at timestamptz
|
|
90
|
+
);
|
|
91
|
+
CREATE TABLE IF NOT EXISTS avelon_failed_jobs (
|
|
92
|
+
id text PRIMARY KEY,
|
|
93
|
+
queue text,
|
|
94
|
+
name text NOT NULL,
|
|
95
|
+
payload jsonb NOT NULL,
|
|
96
|
+
attempt integer NOT NULL,
|
|
97
|
+
error text NOT NULL,
|
|
98
|
+
failed_at timestamptz NOT NULL
|
|
99
|
+
);
|
|
100
|
+
`
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Neon queue driver using Postgres `FOR UPDATE SKIP LOCKED`.
|
|
104
|
+
*
|
|
105
|
+
* Jobs live on the same Neon database as the rest of the stack. Skip-locked tables carry retry,
|
|
106
|
+
* delay, and dead-letter semantics without requiring pgmq.
|
|
107
|
+
*/
|
|
108
|
+
export class NeonQueue
|
|
109
|
+
implements
|
|
110
|
+
QueueDriver<typeof neonQueueCapabilities, SQL>,
|
|
111
|
+
DelayedQueueSurface,
|
|
112
|
+
RetryQueueSurface,
|
|
113
|
+
DeadLetterQueueSurface
|
|
114
|
+
{
|
|
115
|
+
readonly name = 'neon'
|
|
116
|
+
readonly instance: string
|
|
117
|
+
readonly capabilities = neonQueueCapabilities
|
|
118
|
+
|
|
119
|
+
readonly #sql: SQL
|
|
120
|
+
readonly #maxTries: number
|
|
121
|
+
|
|
122
|
+
constructor(options: { url: string; instance: string; maxTries: number }) {
|
|
123
|
+
this.instance = options.instance
|
|
124
|
+
this.#sql = new SQL(options.url)
|
|
125
|
+
this.#maxTries = options.maxTries
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
raw(): SQL {
|
|
129
|
+
return this.#sql
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async reset(): Promise<void> {
|
|
133
|
+
await this.#sql.unsafe('DROP TABLE IF EXISTS avelon_failed_jobs')
|
|
134
|
+
await this.#sql.unsafe('DROP TABLE IF EXISTS avelon_jobs')
|
|
135
|
+
await this.#sql.unsafe(SCHEMA_SQL)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async close(): Promise<void> {
|
|
139
|
+
await this.#sql.close()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async enqueue<TPayload>(job: QueueJob<TPayload>): Promise<string> {
|
|
143
|
+
return this.#insert(job, new Date())
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async enqueueAt<TPayload>(job: QueueJob<TPayload>, availableAt: Date): Promise<string> {
|
|
147
|
+
return this.#insert(job, availableAt)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async drain(
|
|
151
|
+
handler: (receipt: QueueReceipt) => Promise<void>,
|
|
152
|
+
options?: { readonly queue?: string; readonly limit?: number },
|
|
153
|
+
): Promise<number> {
|
|
154
|
+
await this.#sql.unsafe(SCHEMA_SQL)
|
|
155
|
+
const limit = options?.limit ?? 2_147_483_647
|
|
156
|
+
const rows = (await this.#sql.unsafe(
|
|
157
|
+
`WITH picked AS MATERIALIZED (
|
|
158
|
+
SELECT id FROM avelon_jobs
|
|
159
|
+
WHERE available_at <= now()
|
|
160
|
+
AND locked_at IS NULL
|
|
161
|
+
AND ($1::boolean OR queue IS NOT DISTINCT FROM $2)
|
|
162
|
+
ORDER BY available_at ASC
|
|
163
|
+
FOR UPDATE SKIP LOCKED
|
|
164
|
+
LIMIT $3
|
|
165
|
+
)
|
|
166
|
+
UPDATE avelon_jobs AS jobs
|
|
167
|
+
SET locked_at = now(), attempt = jobs.attempt + 1
|
|
168
|
+
FROM picked
|
|
169
|
+
WHERE jobs.id = picked.id
|
|
170
|
+
RETURNING jobs.id, jobs.queue, jobs.name, jobs.payload, jobs.attempt, jobs.available_at`,
|
|
171
|
+
[options?.queue === undefined, options?.queue ?? null, limit],
|
|
172
|
+
)) as JobRow[]
|
|
173
|
+
|
|
174
|
+
for (const row of rows) {
|
|
175
|
+
const receipt: QueueReceipt = { id: row.id, job: toJob(row), attempt: row.attempt }
|
|
176
|
+
try {
|
|
177
|
+
await handler(receipt)
|
|
178
|
+
await this.#sql.unsafe(`DELETE FROM avelon_jobs WHERE id = $1`, [row.id])
|
|
179
|
+
} catch (error: unknown) {
|
|
180
|
+
if (this.capabilities.retries && row.attempt < this.#maxTries) {
|
|
181
|
+
await this.#sql.unsafe(`UPDATE avelon_jobs SET locked_at = NULL WHERE id = $1`, [row.id])
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (this.capabilities.deadLetter) {
|
|
185
|
+
await this.#sql.unsafe(
|
|
186
|
+
`INSERT INTO avelon_failed_jobs (id, queue, name, payload, attempt, error, failed_at)
|
|
187
|
+
VALUES ($1, $2, $3, $4::jsonb, $5, $6, now())`,
|
|
188
|
+
[
|
|
189
|
+
row.id,
|
|
190
|
+
row.queue,
|
|
191
|
+
row.name,
|
|
192
|
+
encodePayload(row.payload),
|
|
193
|
+
row.attempt,
|
|
194
|
+
failureMessage(error),
|
|
195
|
+
],
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
await this.#sql.unsafe(`DELETE FROM avelon_jobs WHERE id = $1`, [row.id])
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return rows.length
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async retry(id: string, delaySeconds?: number): Promise<void> {
|
|
206
|
+
const delay = delaySeconds ?? 0
|
|
207
|
+
await this.#sql.unsafe(
|
|
208
|
+
`UPDATE avelon_jobs
|
|
209
|
+
SET locked_at = NULL, available_at = now() + ($2 * interval '1 second')
|
|
210
|
+
WHERE id = $1`,
|
|
211
|
+
[id, delay],
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async failed(queue?: string): Promise<readonly FailedQueueJob[]> {
|
|
216
|
+
await this.#sql.unsafe(SCHEMA_SQL)
|
|
217
|
+
const rows = (await this.#sql.unsafe(
|
|
218
|
+
`SELECT id, queue, name, payload, attempt, error, failed_at
|
|
219
|
+
FROM avelon_failed_jobs
|
|
220
|
+
WHERE ($1::text IS NULL OR queue IS NOT DISTINCT FROM $1)
|
|
221
|
+
ORDER BY failed_at ASC`,
|
|
222
|
+
[queue ?? null],
|
|
223
|
+
)) as FailedRow[]
|
|
224
|
+
return rows.map((row) => ({
|
|
225
|
+
id: row.id,
|
|
226
|
+
job: toJob(row),
|
|
227
|
+
attempt: row.attempt,
|
|
228
|
+
error: row.error,
|
|
229
|
+
failedAt: asDate(row.failed_at),
|
|
230
|
+
}))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async replay(id: string): Promise<void> {
|
|
234
|
+
const rows = (await this.#sql.unsafe(
|
|
235
|
+
`DELETE FROM avelon_failed_jobs WHERE id = $1
|
|
236
|
+
RETURNING id, queue, name, payload`,
|
|
237
|
+
[id],
|
|
238
|
+
)) as Array<Pick<FailedRow, 'id' | 'queue' | 'name' | 'payload'>>
|
|
239
|
+
const row = rows[0]
|
|
240
|
+
if (row === undefined) return
|
|
241
|
+
await this.#sql.unsafe(
|
|
242
|
+
`INSERT INTO avelon_jobs (id, queue, name, payload, attempt, available_at, locked_at)
|
|
243
|
+
VALUES ($1, $2, $3, $4::jsonb, 0, now(), NULL)`,
|
|
244
|
+
[row.id, row.queue, row.name, encodePayload(row.payload)],
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async forget(id: string): Promise<void> {
|
|
249
|
+
await this.#sql.unsafe(`DELETE FROM avelon_failed_jobs WHERE id = $1`, [id])
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async #insert<TPayload>(job: QueueJob<TPayload>, availableAt: Date): Promise<string> {
|
|
253
|
+
await this.#sql.unsafe(SCHEMA_SQL)
|
|
254
|
+
const id = crypto.randomUUID()
|
|
255
|
+
await this.#sql.unsafe(
|
|
256
|
+
`INSERT INTO avelon_jobs (id, queue, name, payload, attempt, available_at, locked_at)
|
|
257
|
+
VALUES ($1, $2, $3, $4::jsonb, 0, $5, NULL)`,
|
|
258
|
+
[id, job.queue ?? null, job.name, encodePayload(job.payload), availableAt.toISOString()],
|
|
259
|
+
)
|
|
260
|
+
return id
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Creates a skip-locked queue driver from options or environment defaults. */
|
|
265
|
+
export function createNeonQueue(options: NeonQueueOptions = {}): NeonQueue {
|
|
266
|
+
return new NeonQueue({
|
|
267
|
+
url: options.url ?? defaultUrl(),
|
|
268
|
+
instance: options.instance ?? 'default',
|
|
269
|
+
maxTries: options.maxTries ?? 3,
|
|
270
|
+
})
|
|
271
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createNeonQueue, NeonQueue, neonQueueCapabilities, type NeonQueueOptions } from './driver'
|