bunderstack 0.14.0 → 0.15.1
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/package.json +4 -4
- package/src/config.ts +12 -1
- package/src/index.ts +6 -4
- package/src/jobs/cron-runner.ts +122 -10
- package/src/realtime/redis.ts +6 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Batteries-included backend framework for Bun: CRUD APIs, auth, file storage, realtime, tRPC, email, and validated env from a single Drizzle schema and config object.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"backend",
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
"url": "git+https://github.com/kirill-dev-pro/bunderstack.git",
|
|
21
21
|
"directory": "packages/bunderstack"
|
|
22
22
|
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"bunderstack": "./src/cli.ts"
|
|
25
|
+
},
|
|
23
26
|
"files": [
|
|
24
27
|
"src",
|
|
25
28
|
"!src/**/*.test.ts",
|
|
@@ -30,9 +33,6 @@
|
|
|
30
33
|
"main": "./src/index.ts",
|
|
31
34
|
"module": "./src/index.ts",
|
|
32
35
|
"types": "./src/index.ts",
|
|
33
|
-
"bin": {
|
|
34
|
-
"bunderstack": "./src/cli.ts"
|
|
35
|
-
},
|
|
36
36
|
"exports": {
|
|
37
37
|
".": "./src/index.ts",
|
|
38
38
|
"./access": "./src/access.ts",
|
package/src/config.ts
CHANGED
|
@@ -197,12 +197,23 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
197
197
|
export function resolveRealtimeRedisUrl(
|
|
198
198
|
realtime: ResolvedConfig['realtime'],
|
|
199
199
|
env?: BaseEnv,
|
|
200
|
+
platformSource: Record<string, string | undefined> = process.env as Record<
|
|
201
|
+
string,
|
|
202
|
+
string | undefined
|
|
203
|
+
>,
|
|
200
204
|
): string | undefined {
|
|
205
|
+
const platformRedis = platformSource['REDIS_URL']
|
|
206
|
+
if (platformRedis) return platformRedis
|
|
207
|
+
|
|
208
|
+
const envRedis = env?.REDIS_URL
|
|
209
|
+
if (envRedis) return envRedis
|
|
210
|
+
|
|
201
211
|
const fromConfig =
|
|
202
212
|
typeof realtime === 'object' && realtime.redis
|
|
203
213
|
? typeof realtime.redis === 'string'
|
|
204
214
|
? realtime.redis
|
|
205
215
|
: realtime.redis.url
|
|
206
216
|
: undefined
|
|
207
|
-
|
|
217
|
+
|
|
218
|
+
return fromConfig ?? undefined
|
|
208
219
|
}
|
package/src/index.ts
CHANGED
|
@@ -407,6 +407,7 @@ export async function createBunderstack<
|
|
|
407
407
|
? redisUrl
|
|
408
408
|
? createRedisRealtimeBroker({
|
|
409
409
|
access: resolvedAccess,
|
|
410
|
+
channel: process.env.BUNDERSTACK_REALTIME_CHANNEL || undefined,
|
|
410
411
|
redis: () => {
|
|
411
412
|
// Redis pub/sub requires a dedicated connection (subscribe puts the client into
|
|
412
413
|
// a restricted state). We use one client for commands and a second for subscribe.
|
|
@@ -487,9 +488,7 @@ export async function createBunderstack<
|
|
|
487
488
|
},
|
|
488
489
|
async getUrl(key, opts = {}) {
|
|
489
490
|
const bucketName =
|
|
490
|
-
opts.bucket ??
|
|
491
|
-
key.split('/')[0] ??
|
|
492
|
-
config.storage.defaultBucket
|
|
491
|
+
opts.bucket ?? key.split('/')[0] ?? config.storage.defaultBucket
|
|
493
492
|
const adapter = registry.get(bucketName)?.adapter
|
|
494
493
|
if (adapter?.presignGet) {
|
|
495
494
|
return adapter.presignGet(key, {
|
|
@@ -503,7 +502,10 @@ export async function createBunderstack<
|
|
|
503
502
|
const adapter = registry.get(bucketName)?.adapter
|
|
504
503
|
if (!adapter) throw new Error(`Unknown bucket: ${bucketName}`)
|
|
505
504
|
const u8 = body instanceof Uint8Array ? body : new Uint8Array(body)
|
|
506
|
-
const buf: ArrayBuffer = u8.buffer.slice(
|
|
505
|
+
const buf: ArrayBuffer = u8.buffer.slice(
|
|
506
|
+
u8.byteOffset,
|
|
507
|
+
u8.byteOffset + u8.byteLength,
|
|
508
|
+
) as ArrayBuffer
|
|
507
509
|
await adapter.upload(key, buf, contentType)
|
|
508
510
|
await insertReadyFile(db, {
|
|
509
511
|
fileId: key,
|
package/src/jobs/cron-runner.ts
CHANGED
|
@@ -20,14 +20,22 @@ export async function runScheduledSlot(args: {
|
|
|
20
20
|
slot: number
|
|
21
21
|
now: number
|
|
22
22
|
run: (scheduledFor: Date) => Promise<void> | void
|
|
23
|
+
leaseMs?: number
|
|
24
|
+
heartbeatIntervalMs?: number
|
|
25
|
+
heartbeatCleanupTimeoutMs?: number
|
|
23
26
|
}): Promise<CronRunResult> {
|
|
24
27
|
const { db, taskId, schedule, slot, now, run } = args
|
|
28
|
+
const leaseMs = args.leaseMs ?? LEASE_MS
|
|
29
|
+
const heartbeatIntervalMs =
|
|
30
|
+
args.heartbeatIntervalMs ?? Math.max(1, Math.floor(leaseMs / 4))
|
|
31
|
+
const heartbeatCleanupTimeoutMs = args.heartbeatCleanupTimeoutMs ?? 1_000
|
|
32
|
+
|
|
25
33
|
if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
|
|
26
34
|
throw new Error('[bunderstack] cron slot does not match its schedule')
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
const t = cronRunsTableFor(db)
|
|
30
|
-
const leaseUntil = now +
|
|
38
|
+
const leaseUntil = now + leaseMs
|
|
31
39
|
const inserted = await db
|
|
32
40
|
.insert(t)
|
|
33
41
|
.values({
|
|
@@ -39,9 +47,12 @@ export async function runScheduledSlot(args: {
|
|
|
39
47
|
startedAt: now,
|
|
40
48
|
})
|
|
41
49
|
.onConflictDoNothing({ target: [t.taskId, t.scheduledAt] })
|
|
42
|
-
.returning({ taskId: t.taskId })
|
|
50
|
+
.returning({ taskId: t.taskId, attempts: t.attempts })
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
let ownershipAttempt: number
|
|
53
|
+
if (inserted[0]) {
|
|
54
|
+
ownershipAttempt = Number(inserted[0].attempts)
|
|
55
|
+
} else {
|
|
45
56
|
const existing = await db
|
|
46
57
|
.select({ status: t.status, lockedUntil: t.lockedUntil })
|
|
47
58
|
.from(t)
|
|
@@ -68,24 +79,118 @@ export async function runScheduledSlot(args: {
|
|
|
68
79
|
or(eq(t.status, 'failed'), lt(t.lockedUntil, now)),
|
|
69
80
|
),
|
|
70
81
|
)
|
|
71
|
-
.returning({ taskId: t.taskId })
|
|
72
|
-
|
|
82
|
+
.returning({ taskId: t.taskId, attempts: t.attempts })
|
|
83
|
+
const reclaimedRow = reclaimed[0]
|
|
84
|
+
if (!reclaimedRow) return { status: 'running' }
|
|
85
|
+
ownershipAttempt = Number(reclaimedRow.attempts)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let heartbeatTimer: Timer | undefined
|
|
89
|
+
let heartbeatInFlight: Promise<void> | undefined
|
|
90
|
+
let heartbeatStopped = false
|
|
91
|
+
|
|
92
|
+
const scheduleHeartbeat = () => {
|
|
93
|
+
heartbeatTimer = setTimeout(() => {
|
|
94
|
+
heartbeatTimer = undefined
|
|
95
|
+
if (heartbeatStopped) return
|
|
96
|
+
|
|
97
|
+
heartbeatInFlight = (async () => {
|
|
98
|
+
try {
|
|
99
|
+
const renewUntil = Date.now() + leaseMs
|
|
100
|
+
await db
|
|
101
|
+
.update(t)
|
|
102
|
+
.set({ lockedUntil: renewUntil })
|
|
103
|
+
.where(
|
|
104
|
+
and(
|
|
105
|
+
eq(t.taskId, taskId),
|
|
106
|
+
eq(t.scheduledAt, slot),
|
|
107
|
+
eq(t.status, 'running'),
|
|
108
|
+
eq(t.startedAt, now),
|
|
109
|
+
eq(t.attempts, ownershipAttempt),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
} catch {
|
|
113
|
+
// Best effort renewal
|
|
114
|
+
}
|
|
115
|
+
})()
|
|
116
|
+
|
|
117
|
+
void heartbeatInFlight.finally(() => {
|
|
118
|
+
heartbeatInFlight = undefined
|
|
119
|
+
if (!heartbeatStopped) scheduleHeartbeat()
|
|
120
|
+
})
|
|
121
|
+
}, heartbeatIntervalMs)
|
|
73
122
|
}
|
|
74
123
|
|
|
124
|
+
const stopHeartbeat = async () => {
|
|
125
|
+
heartbeatStopped = true
|
|
126
|
+
if (heartbeatTimer) {
|
|
127
|
+
clearTimeout(heartbeatTimer)
|
|
128
|
+
heartbeatTimer = undefined
|
|
129
|
+
}
|
|
130
|
+
const inFlight = heartbeatInFlight
|
|
131
|
+
if (!inFlight) return
|
|
132
|
+
|
|
133
|
+
let cleanupTimer: Timer | undefined
|
|
134
|
+
try {
|
|
135
|
+
await Promise.race([
|
|
136
|
+
inFlight,
|
|
137
|
+
new Promise<void>((resolve) => {
|
|
138
|
+
cleanupTimer = setTimeout(resolve, heartbeatCleanupTimeoutMs)
|
|
139
|
+
}),
|
|
140
|
+
])
|
|
141
|
+
} finally {
|
|
142
|
+
if (cleanupTimer) clearTimeout(cleanupTimer)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
scheduleHeartbeat()
|
|
147
|
+
|
|
75
148
|
try {
|
|
76
149
|
await run(new Date(slot))
|
|
77
|
-
|
|
150
|
+
|
|
151
|
+
const updated = await db
|
|
78
152
|
.update(t)
|
|
79
153
|
.set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
|
|
80
|
-
.where(
|
|
154
|
+
.where(
|
|
155
|
+
and(
|
|
156
|
+
eq(t.taskId, taskId),
|
|
157
|
+
eq(t.scheduledAt, slot),
|
|
158
|
+
eq(t.status, 'running'),
|
|
159
|
+
eq(t.startedAt, now),
|
|
160
|
+
eq(t.attempts, ownershipAttempt),
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
.returning({ taskId: t.taskId })
|
|
164
|
+
|
|
165
|
+
if (!updated[0]) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
'[bunderstack] cron lease ownership was lost during execution',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
81
171
|
return { status: 'succeeded' }
|
|
82
172
|
} catch (error) {
|
|
83
173
|
const message = error instanceof Error ? error.message : String(error)
|
|
84
174
|
await db
|
|
85
175
|
.update(t)
|
|
86
|
-
.set({
|
|
87
|
-
|
|
176
|
+
.set({
|
|
177
|
+
status: 'failed',
|
|
178
|
+
lockedUntil: null,
|
|
179
|
+
lastError: message,
|
|
180
|
+
finishedAt: Date.now(),
|
|
181
|
+
})
|
|
182
|
+
.where(
|
|
183
|
+
and(
|
|
184
|
+
eq(t.taskId, taskId),
|
|
185
|
+
eq(t.scheduledAt, slot),
|
|
186
|
+
eq(t.status, 'running'),
|
|
187
|
+
eq(t.startedAt, now),
|
|
188
|
+
eq(t.attempts, ownershipAttempt),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
88
191
|
throw error
|
|
192
|
+
} finally {
|
|
193
|
+
await stopHeartbeat()
|
|
89
194
|
}
|
|
90
195
|
}
|
|
91
196
|
|
|
@@ -96,6 +201,9 @@ export async function runCronSlot(args: {
|
|
|
96
201
|
name: string
|
|
97
202
|
slot: number
|
|
98
203
|
now: number
|
|
204
|
+
leaseMs?: number
|
|
205
|
+
heartbeatIntervalMs?: number
|
|
206
|
+
heartbeatCleanupTimeoutMs?: number
|
|
99
207
|
}): Promise<CronRunResult> {
|
|
100
208
|
const definition = args.defs[args.name]
|
|
101
209
|
if (!definition || definition.kind !== 'cron') {
|
|
@@ -107,6 +215,10 @@ export async function runCronSlot(args: {
|
|
|
107
215
|
schedule: definition.schedule,
|
|
108
216
|
slot: args.slot,
|
|
109
217
|
now: args.now,
|
|
110
|
-
|
|
218
|
+
leaseMs: args.leaseMs,
|
|
219
|
+
heartbeatIntervalMs: args.heartbeatIntervalMs,
|
|
220
|
+
heartbeatCleanupTimeoutMs: args.heartbeatCleanupTimeoutMs,
|
|
221
|
+
run: (scheduledFor) =>
|
|
222
|
+
definition.handler({ scheduledFor }, args.ctx as never),
|
|
111
223
|
})
|
|
112
224
|
}
|
package/src/realtime/redis.ts
CHANGED
|
@@ -125,7 +125,8 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
125
125
|
if (typeof entry.get === 'function') return false
|
|
126
126
|
if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
|
|
127
127
|
return false
|
|
128
|
-
if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx)))
|
|
128
|
+
if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx)))
|
|
129
|
+
return false
|
|
129
130
|
return true
|
|
130
131
|
}
|
|
131
132
|
|
|
@@ -140,7 +141,10 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
140
141
|
let closed = false
|
|
141
142
|
|
|
142
143
|
const start = () => {
|
|
143
|
-
if (closed)
|
|
144
|
+
if (closed)
|
|
145
|
+
return Promise.reject(
|
|
146
|
+
new Error('[bunderstack] realtime broker is closed'),
|
|
147
|
+
)
|
|
144
148
|
started ??= getRedis()
|
|
145
149
|
.subscribe(channel, (message) => {
|
|
146
150
|
const evt = parseWireEvent(message)
|