bunderstack 0.14.0 → 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
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,20 @@ 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
23
25
  }): Promise<CronRunResult> {
24
26
  const { db, taskId, schedule, slot, now, run } = args
27
+ const leaseMs = args.leaseMs ?? LEASE_MS
28
+ const heartbeatIntervalMs =
29
+ args.heartbeatIntervalMs ?? Math.max(1, Math.floor(leaseMs / 4))
30
+
25
31
  if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
26
32
  throw new Error('[bunderstack] cron slot does not match its schedule')
27
33
  }
28
34
 
29
35
  const t = cronRunsTableFor(db)
30
- const leaseUntil = now + LEASE_MS
36
+ const leaseUntil = now + leaseMs
31
37
  const inserted = await db
32
38
  .insert(t)
33
39
  .values({
@@ -72,20 +78,81 @@ export async function runScheduledSlot(args: {
72
78
  if (!reclaimed[0]) return { status: 'running' }
73
79
  }
74
80
 
81
+ let heartbeatTimer: Timer | undefined
82
+ const stopHeartbeat = () => {
83
+ if (heartbeatTimer) {
84
+ clearInterval(heartbeatTimer)
85
+ heartbeatTimer = undefined
86
+ }
87
+ }
88
+
89
+ heartbeatTimer = setInterval(async () => {
90
+ try {
91
+ const renewUntil = Date.now() + leaseMs
92
+ await db
93
+ .update(t)
94
+ .set({ lockedUntil: renewUntil })
95
+ .where(
96
+ and(
97
+ eq(t.taskId, taskId),
98
+ eq(t.scheduledAt, slot),
99
+ eq(t.status, 'running'),
100
+ eq(t.startedAt, now),
101
+ ),
102
+ )
103
+ } catch {
104
+ // Best effort renewal
105
+ }
106
+ }, heartbeatIntervalMs)
107
+
75
108
  try {
76
109
  await run(new Date(slot))
77
- await db
110
+
111
+ stopHeartbeat()
112
+
113
+ const updated = await db
78
114
  .update(t)
79
115
  .set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
80
- .where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
116
+ .where(
117
+ and(
118
+ eq(t.taskId, taskId),
119
+ eq(t.scheduledAt, slot),
120
+ eq(t.status, 'running'),
121
+ eq(t.startedAt, now),
122
+ ),
123
+ )
124
+ .returning({ taskId: t.taskId })
125
+
126
+ if (!updated[0]) {
127
+ throw new Error(
128
+ '[bunderstack] cron lease ownership was lost during execution',
129
+ )
130
+ }
131
+
81
132
  return { status: 'succeeded' }
82
133
  } catch (error) {
134
+ stopHeartbeat()
135
+
83
136
  const message = error instanceof Error ? error.message : String(error)
84
137
  await db
85
138
  .update(t)
86
- .set({ status: 'failed', lockedUntil: null, lastError: message, finishedAt: Date.now() })
87
- .where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
139
+ .set({
140
+ status: 'failed',
141
+ lockedUntil: null,
142
+ lastError: message,
143
+ finishedAt: Date.now(),
144
+ })
145
+ .where(
146
+ and(
147
+ eq(t.taskId, taskId),
148
+ eq(t.scheduledAt, slot),
149
+ eq(t.status, 'running'),
150
+ eq(t.startedAt, now),
151
+ ),
152
+ )
88
153
  throw error
154
+ } finally {
155
+ stopHeartbeat()
89
156
  }
90
157
  }
91
158
 
@@ -96,6 +163,8 @@ export async function runCronSlot(args: {
96
163
  name: string
97
164
  slot: number
98
165
  now: number
166
+ leaseMs?: number
167
+ heartbeatIntervalMs?: number
99
168
  }): Promise<CronRunResult> {
100
169
  const definition = args.defs[args.name]
101
170
  if (!definition || definition.kind !== 'cron') {
@@ -107,6 +176,9 @@ export async function runCronSlot(args: {
107
176
  schedule: definition.schedule,
108
177
  slot: args.slot,
109
178
  now: args.now,
110
- run: (scheduledFor) => definition.handler({ scheduledFor }, args.ctx as never),
179
+ leaseMs: args.leaseMs,
180
+ heartbeatIntervalMs: args.heartbeatIntervalMs,
181
+ run: (scheduledFor) =>
182
+ definition.handler({ scheduledFor }, args.ctx as never),
111
183
  })
112
184
  }
@@ -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)