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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.14.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
- return fromConfig ?? env?.REDIS_URL ?? process.env.REDIS_URL ?? undefined
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(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer
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,
@@ -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 + LEASE_MS
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
- if (!inserted[0]) {
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
- if (!reclaimed[0]) return { status: 'running' }
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
- await db
150
+
151
+ const updated = await db
78
152
  .update(t)
79
153
  .set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
80
- .where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
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({ status: 'failed', lockedUntil: null, lastError: message, finishedAt: Date.now() })
87
- .where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
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
- run: (scheduledFor) => definition.handler({ scheduledFor }, args.ctx as never),
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
  }
@@ -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))) return false
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) return Promise.reject(new Error('[bunderstack] realtime broker is 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)