bunderstack 0.4.0 → 0.6.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.
@@ -1,8 +1,5 @@
1
- // src/jobs/worker.ts — the in-process worker. One `tick()` is a full cycle:
2
- // recover expired leases → schedule cron slotsreap old succeeded rows
3
- // claim and run claimable jobs (awaiting handlers, so tests drive `tick()`
4
- // deterministically with an injected `now`). Multiple replicas run the same
5
- // loop safely: claims are atomic and cron slots dedupe on a unique index.
1
+ // src/jobs/worker.ts — the queue worker. One `tick()` is a full cycle:
2
+ // recover expired leases → reap old succeeded rows claim and run queue jobs.
6
3
  import { and, eq, inArray, is, isNotNull, lt, lte, sql } from 'drizzle-orm'
7
4
  import { PgDatabase } from 'drizzle-orm/pg-core'
8
5
 
@@ -12,12 +9,9 @@ import type {
12
9
  JobsDefs,
13
10
  JobsRuntimeFacade,
14
11
  } from './define'
15
- import type { ParsedCron } from './cron'
16
12
 
17
13
  import { jobsTableFor } from '../internal-tables'
18
- import { cronMatches, parseCron } from './cron'
19
14
  import { backoffMs, DEFAULT_RETRIES, DEFAULT_TIMEOUT_MS } from './define'
20
- import { enqueueJob } from './queue'
21
15
 
22
16
  const CLAIM_BATCH = 10
23
17
  const SUCCEEDED_RETENTION_MS = 24 * 60 * 60 * 1000
@@ -37,9 +31,9 @@ function maxAttempts(def: AnyJobDefinition): number {
37
31
  return 1 + (def.retries ?? DEFAULT_RETRIES)
38
32
  }
39
33
 
40
- /** Terminal-status column patch: non-cron jobs release their dedupe key. */
41
- function terminalPatch(def: AnyJobDefinition | undefined) {
42
- return def?.cron ? {} : { dedupeKey: null }
34
+ /** Terminal queue rows release their dedupe key. */
35
+ function terminalPatch() {
36
+ return { dedupeKey: null }
43
37
  }
44
38
 
45
39
  export function createJobRunner(deps: {
@@ -51,11 +45,6 @@ export function createJobRunner(deps: {
51
45
  const { db, defs } = deps
52
46
  const t = jobsTableFor(db)
53
47
  const ctx = { ...deps.ctx } as Record<string, unknown>
54
- const crons = new Map<string, ParsedCron>()
55
- for (const [name, def] of Object.entries(defs)) {
56
- if (def.cron) crons.set(name, parseCron(def.cron))
57
- }
58
-
59
48
  async function fireOnFailed(
60
49
  def: AnyJobDefinition,
61
50
  payloadJson: string,
@@ -93,7 +82,7 @@ export function createJobRunner(deps: {
93
82
  for (const row of expired) {
94
83
  const def = defs[row.type]
95
84
  const error = new Error('lease expired (worker crashed or timed out)')
96
- if (!def) {
85
+ if (!def || def.kind !== 'job') {
97
86
  await db
98
87
  .update(t)
99
88
  .set({
@@ -114,7 +103,7 @@ export function createJobRunner(deps: {
114
103
  finishedAt: now,
115
104
  lockedUntil: null,
116
105
  lastError: error.message,
117
- ...terminalPatch(def),
106
+ ...terminalPatch(),
118
107
  })
119
108
  .where(eq(t.id, row.id))
120
109
  await fireOnFailed(def, row.payloadJson, error)
@@ -132,20 +121,6 @@ export function createJobRunner(deps: {
132
121
  }
133
122
  }
134
123
 
135
- /** Enqueue the current minute's slot for every cron definition. */
136
- async function scheduleCronSlots(now: number) {
137
- const minute = Math.floor(now / 60_000) * 60_000
138
- for (const [name, cron] of crons) {
139
- if (!cronMatches(cron, minute)) continue
140
- // The unique (type, dedupe_key) index collapses concurrent replicas'
141
- // enqueues of the same slot into one row.
142
- await enqueueJob(db, defs, name, undefined, {
143
- dedupeKey: `cron:${name}:${minute}`,
144
- runAt: minute,
145
- })
146
- }
147
- }
148
-
149
124
  async function reapSucceeded(now: number) {
150
125
  await db
151
126
  .delete(t)
@@ -211,7 +186,7 @@ export function createJobRunner(deps: {
211
186
  finishedAt: Date.now(),
212
187
  lockedUntil: null,
213
188
  lastError: e.message,
214
- ...terminalPatch(def),
189
+ ...terminalPatch(),
215
190
  })
216
191
  .where(eq(t.id, row.id))
217
192
  await fireOnFailed(def, row.payloadJson, e)
@@ -225,7 +200,7 @@ export function createJobRunner(deps: {
225
200
  status: 'succeeded',
226
201
  finishedAt: Date.now(),
227
202
  lockedUntil: null,
228
- ...terminalPatch(def),
203
+ ...terminalPatch(),
229
204
  })
230
205
  .where(eq(t.id, row.id))
231
206
  } catch (err) {
@@ -248,7 +223,7 @@ export function createJobRunner(deps: {
248
223
  finishedAt: Date.now(),
249
224
  lockedUntil: null,
250
225
  lastError: e.message,
251
- ...terminalPatch(def),
226
+ ...terminalPatch(),
252
227
  })
253
228
  .where(eq(t.id, row.id))
254
229
  await fireOnFailed(def, row.payloadJson, e)
@@ -258,7 +233,9 @@ export function createJobRunner(deps: {
258
233
 
259
234
  async function runClaimable(now: number) {
260
235
  const work: Promise<void>[] = []
261
- for (const [type, def] of Object.entries(defs)) {
236
+ for (const [type, candidate] of Object.entries(defs)) {
237
+ if (candidate.kind !== 'job') continue
238
+ const def = candidate
262
239
  let limit = CLAIM_BATCH
263
240
  if (def.concurrency !== undefined) {
264
241
  const runningRows = await db
@@ -279,7 +256,6 @@ export function createJobRunner(deps: {
279
256
  return {
280
257
  async tick(now: number = Date.now()) {
281
258
  await recoverExpiredLeases(now)
282
- await scheduleCronSlots(now)
283
259
  await reapSucceeded(now)
284
260
  await runClaimable(now)
285
261
  },
@@ -0,0 +1,44 @@
1
+ export type Cleanup = () => void | Promise<void>
2
+ export type LifecycleStatus = 'ready' | 'closing' | 'closed'
3
+
4
+ export class Lifecycle {
5
+ #controller = new AbortController()
6
+ #status: LifecycleStatus = 'ready'
7
+ #cleanups = new Set<Cleanup>()
8
+ #closePromise: Promise<void> | undefined
9
+
10
+ get signal(): AbortSignal {
11
+ return this.#controller.signal
12
+ }
13
+
14
+ get status(): LifecycleStatus {
15
+ return this.#status
16
+ }
17
+
18
+ add(cleanup: Cleanup): () => void {
19
+ if (this.#status !== 'ready') {
20
+ throw new Error('[bunderstack] application lifecycle is closed')
21
+ }
22
+ this.#cleanups.add(cleanup)
23
+ return () => this.#cleanups.delete(cleanup)
24
+ }
25
+
26
+ close(): Promise<void> {
27
+ if (this.#closePromise) return this.#closePromise
28
+ this.#status = 'closing'
29
+ this.#controller.abort()
30
+ this.#closePromise = (async () => {
31
+ const cleanups = [...this.#cleanups].reverse()
32
+ this.#cleanups.clear()
33
+ const results = await Promise.allSettled(cleanups.map((cleanup) => cleanup()))
34
+ this.#status = 'closed'
35
+ const errors = results.flatMap((result) =>
36
+ result.status === 'rejected' ? [result.reason] : [],
37
+ )
38
+ if (errors.length > 0) {
39
+ throw new AggregateError(errors, '[bunderstack] lifecycle cleanup failed')
40
+ }
41
+ })()
42
+ return this.#closePromise
43
+ }
44
+ }
package/src/manifest.ts CHANGED
@@ -3,23 +3,46 @@
3
3
  // Deployment platforms (Bunderhost) import the app declaration with
4
4
  // BUNDERSTACK_INTROSPECT=1 and read `app.manifest` to learn what to provision.
5
5
  import type { ZodType } from 'zod'
6
+ import { getTableName, isTable } from 'drizzle-orm'
6
7
 
7
8
  import type { Dialect } from './dialect'
8
9
  import type { EnvConfigInput } from './env'
9
10
  import type { JobsDefs } from './jobs/define'
10
11
  import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
12
+ import {
13
+ bunderstackCronRuns,
14
+ bunderstackFiles,
15
+ bunderstackJobs,
16
+ } from './internal-tables'
11
17
 
12
18
  export type ManifestEnvVar = { key: string; required: boolean }
13
- export type ManifestJob = { name: string; cron?: string }
14
-
15
19
  export type BunderstackManifest = {
20
+ version: 2
16
21
  dialect: Dialect
17
22
  tables: string[]
23
+ tableMap: Record<string, string>
24
+ systemTables: {
25
+ jobs: string
26
+ files: string
27
+ scheduledRuns: string
28
+ }
18
29
  defaultBucket: string
19
30
  buckets: { name: string; visibility: ResolvedBucket['visibility'] }[]
20
31
  realtime: boolean
21
32
  env: { server: ManifestEnvVar[]; client: ManifestEnvVar[] }
22
- jobs: ManifestJob[]
33
+ background: {
34
+ jobs: { name: string }[]
35
+ cron: { name: string; schedule: string; timezone: 'UTC' }[]
36
+ maintenance: { name: 'storage-sweep'; schedule: string }[]
37
+ }
38
+ }
39
+
40
+ function describeTables(schema: Record<string, unknown>): Record<string, string> {
41
+ return Object.fromEntries(
42
+ Object.entries(schema).flatMap(([key, value]) =>
43
+ isTable(value) ? [[key, getTableName(value)]] : [],
44
+ ),
45
+ )
23
46
  }
24
47
 
25
48
  function describeSection(
@@ -40,8 +63,15 @@ export function buildManifest(args: {
40
63
  jobs: JobsDefs | undefined
41
64
  }): BunderstackManifest {
42
65
  return {
66
+ version: 2,
43
67
  dialect: args.dialect,
44
68
  tables: Object.keys(args.schema),
69
+ tableMap: describeTables(args.schema),
70
+ systemTables: {
71
+ jobs: getTableName(bunderstackJobs),
72
+ files: getTableName(bunderstackFiles),
73
+ scheduledRuns: getTableName(bunderstackCronRuns),
74
+ },
45
75
  defaultBucket: args.storage.defaultBucket,
46
76
  buckets: [...args.storage.buckets.values()].map((bucket) => ({
47
77
  name: bucket.name,
@@ -52,8 +82,18 @@ export function buildManifest(args: {
52
82
  server: describeSection(args.envConfig?.server),
53
83
  client: describeSection(args.envConfig?.client),
54
84
  },
55
- jobs: Object.entries(args.jobs ?? {}).map(([name, def]) =>
56
- def.cron !== undefined ? { name, cron: def.cron } : { name },
57
- ),
85
+ background: {
86
+ jobs: Object.entries(args.jobs ?? {})
87
+ .filter(([, def]) => def.kind === 'job')
88
+ .map(([name]) => ({ name })),
89
+ cron: Object.entries(args.jobs ?? {})
90
+ .filter(([, def]) => def.kind === 'cron')
91
+ .map(([name, def]) => ({
92
+ name,
93
+ schedule: def.kind === 'cron' ? def.schedule : '',
94
+ timezone: 'UTC' as const,
95
+ })),
96
+ maintenance: [{ name: 'storage-sweep', schedule: '0 4 * * *' }],
97
+ },
58
98
  }
59
99
  }
@@ -35,6 +35,8 @@ type RealtimeContextBody = {
35
35
  }
36
36
 
37
37
  export type RealtimeBroker = {
38
+ start(): Promise<void>
39
+ close(): Promise<void>
38
40
  register(send: (data: string) => void): { id: string }
39
41
  setContext(
40
42
  id: string,
@@ -84,8 +86,8 @@ function scopeOk(
84
86
  ctx: Parameters<typeof checkAccessSync>[1],
85
87
  record: Record<string, unknown>,
86
88
  ): boolean {
87
- if (!entry.scope) return true
88
- return rowMatchesScope(record, entry.scope(ctx))
89
+ if (!entry.readScope) return true
90
+ return rowMatchesScope(record, entry.readScope(ctx))
89
91
  }
90
92
 
91
93
  export function buildRealtimeRouter(
@@ -101,7 +103,8 @@ export function buildRealtimeRouter(
101
103
  let keepalive: ReturnType<typeof setInterval>
102
104
 
103
105
  const stream = new ReadableStream({
104
- start(controller) {
106
+ async start(controller) {
107
+ await broker.start()
105
108
  const send = (data: string) =>
106
109
  controller.enqueue(encoder.encode(`data: ${data}\n\n`))
107
110
  handle = broker.register(send)
@@ -183,6 +186,8 @@ export function createRealtimeBroker(opts: {
183
186
  }
184
187
 
185
188
  return {
189
+ async start() {},
190
+ async close() {},
186
191
  register(send) {
187
192
  const id = crypto.randomUUID()
188
193
  subscribers.set(id, {
@@ -27,6 +27,7 @@ export type RedisLike = {
27
27
  lpush(key: string, value: string): Promise<unknown>
28
28
  ltrim(key: string, start: number, stop: number): Promise<unknown>
29
29
  lrange(key: string, start: number, stop: number): Promise<string[]>
30
+ close?(): void | Promise<void>
30
31
  }
31
32
 
32
33
  type Subscriber = {
@@ -86,15 +87,22 @@ function parseWireEvent(raw: string): WireEvent | null {
86
87
 
87
88
  export function createRedisRealtimeBroker(opts: {
88
89
  access: ResolvedAccess
89
- redis: RedisLike
90
+ /** A factory keeps Redis completely cold until SSE or a publish needs it. */
91
+ redis: RedisLike | (() => RedisLike)
90
92
  bufferSize?: number
91
93
  channel?: string
92
- }): RealtimeBroker & { ready: Promise<void> } {
94
+ }): RealtimeBroker {
93
95
  const subscribers = new Map<string, Subscriber>()
94
96
  const bufferSize = opts.bufferSize ?? 1000
95
97
  const channel = opts.channel ?? 'bunderstack:realtime'
96
98
  const logKey = `${channel}:log`
97
99
  const counterKey = `${channel}:seq`
100
+ let redis: RedisLike | undefined
101
+
102
+ const getRedis = () => {
103
+ redis ??= typeof opts.redis === 'function' ? opts.redis() : opts.redis
104
+ return redis
105
+ }
98
106
 
99
107
  function deliverable(
100
108
  s: Subscriber,
@@ -117,7 +125,7 @@ export function createRedisRealtimeBroker(opts: {
117
125
  if (typeof entry.get === 'function') return false
118
126
  if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
119
127
  return false
120
- if (entry.scope && !rowMatchesScope(record, entry.scope(ctx))) return false
128
+ if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx))) return false
121
129
  return true
122
130
  }
123
131
 
@@ -128,16 +136,27 @@ export function createRedisRealtimeBroker(opts: {
128
136
  }
129
137
  }
130
138
 
131
- // Subscribe once; all local delivery happens here.
132
- const ready = opts.redis
133
- .subscribe(channel, (message) => {
134
- const evt = parseWireEvent(message)
135
- if (evt) fanOut(evt)
136
- })
137
- .then(() => undefined)
139
+ let started: Promise<void> | undefined
140
+ let closed = false
141
+
142
+ const start = () => {
143
+ if (closed) return Promise.reject(new Error('[bunderstack] realtime broker is closed'))
144
+ started ??= getRedis()
145
+ .subscribe(channel, (message) => {
146
+ const evt = parseWireEvent(message)
147
+ if (evt) fanOut(evt)
148
+ })
149
+ .then(() => undefined)
150
+ return started
151
+ }
138
152
 
139
153
  return {
140
- ready,
154
+ start,
155
+ async close() {
156
+ closed = true
157
+ subscribers.clear()
158
+ await redis?.close?.()
159
+ },
141
160
  register(send) {
142
161
  const id = crypto.randomUUID()
143
162
  subscribers.set(id, {
@@ -164,7 +183,7 @@ export function createRedisRealtimeBroker(opts: {
164
183
  // to a full refetch rather than 500-ing the POST handler.
165
184
  let raw: string[]
166
185
  try {
167
- raw = await opts.redis.lrange(logKey, 0, bufferSize - 1)
186
+ raw = await getRedis().lrange(logKey, 0, bufferSize - 1)
168
187
  } catch {
169
188
  return { gap: true }
170
189
  }
@@ -187,12 +206,13 @@ export function createRedisRealtimeBroker(opts: {
187
206
  async publish(table, action, record) {
188
207
  if (!tableEntry(opts.access, table)) return
189
208
  try {
190
- const eventId = await opts.redis.incr(counterKey)
209
+ const client = getRedis()
210
+ const eventId = await client.incr(counterKey)
191
211
  const evt: WireEvent = { eventId, table, action, record }
192
212
  const msg = JSON.stringify(evt)
193
- await opts.redis.lpush(logKey, msg)
194
- await opts.redis.ltrim(logKey, 0, bufferSize - 1)
195
- await opts.redis.publish(channel, msg)
213
+ await client.lpush(logKey, msg)
214
+ await client.ltrim(logKey, 0, bufferSize - 1)
215
+ await client.publish(channel, msg)
196
216
  } catch {
197
217
  // Broadcast is best-effort: a redis blip must not reject the floating
198
218
  // promise (callers use `void broker?.publish(...)` with no .catch).
@@ -25,7 +25,10 @@ export type BucketConfigInput = {
25
25
  }
26
26
  upload?: { maxSize?: string | number; accept?: string[] }
27
27
  transforms?: boolean
28
- scope?: ScopeResolver
28
+ scope?: {
29
+ read?: ScopeResolver
30
+ write?: ScopeResolver
31
+ }
29
32
  quota?: { perUser?: string | number; perScope?: string | number }
30
33
  } & Partial<BucketBackendInput>
31
34
 
@@ -59,7 +62,8 @@ export type ResolvedBucket = {
59
62
  access: { create: OperationRule; get: OperationRule; delete: OperationRule }
60
63
  upload?: { maxSizeBytes?: number; accept?: string[] }
61
64
  transforms: boolean
62
- scope?: ScopeResolver
65
+ readScope?: ScopeResolver
66
+ writeScope?: ScopeResolver
63
67
  quota?: { perUserBytes?: number; perScopeBytes?: number }
64
68
  }
65
69
 
@@ -248,7 +252,8 @@ function resolveSingleBucket(
248
252
  access,
249
253
  upload,
250
254
  transforms: input.transforms ?? false,
251
- scope: input.scope,
255
+ readScope: input.scope?.read,
256
+ writeScope: input.scope?.write,
252
257
  quota,
253
258
  }
254
259
  }
@@ -165,7 +165,7 @@ export function buildBucketStorageRouter(
165
165
  const contentType =
166
166
  typeof body.contentType === 'string' ? body.contentType : undefined
167
167
 
168
- const requesterScope = bucket.scope?.(ctx)
168
+ const requesterScope = bucket.writeScope?.(ctx)
169
169
  const scopeJson = scopeToJson(requesterScope)
170
170
 
171
171
  // Quota pre-check: reserve the configured max upload size.
@@ -252,7 +252,7 @@ export function buildBucketStorageRouter(
252
252
  return apiError(c, ErrorCode.VALIDATION_ERROR, 'File too large', 422)
253
253
  }
254
254
 
255
- const requesterScope = bucket.scope?.(ctx)
255
+ const requesterScope = bucket.readScope?.(ctx)
256
256
  const scopeJson = scopeToJson(requesterScope)
257
257
 
258
258
  if (bucket.quota) {
@@ -395,7 +395,7 @@ export function buildBucketStorageRouter(
395
395
  const denied = await gate(bucket.access.get, ctx, c)
396
396
  if (denied) return denied
397
397
 
398
- const requesterScope = bucket.scope?.(ctx)
398
+ const requesterScope = bucket.readScope?.(ctx)
399
399
  if (!fileMatchesScope(row, requesterScope)) {
400
400
  return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
401
401
  }
@@ -491,7 +491,7 @@ export function buildBucketStorageRouter(
491
491
  const denied = await gate(bucket.access.delete, ctx, c)
492
492
  if (denied) return denied
493
493
 
494
- const requesterScope = bucket.scope?.(ctx)
494
+ const requesterScope = bucket.readScope?.(ctx)
495
495
  if (!fileMatchesScope(row, requesterScope)) {
496
496
  return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
497
497
  }