bunderstack 0.6.0 → 0.7.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/README.md CHANGED
@@ -79,6 +79,32 @@ for local standalone development. `app.manifest.background` tells Bunderhost
79
79
  whether to deploy an always-on worker (queue jobs) or only HTTP-delivered cron
80
80
  (cron-only).
81
81
 
82
+ ### Publishing custom writes to realtime
83
+
84
+ Generated CRUD publishes automatically. Writes made directly through `app.db`
85
+ or `ctx.db` are explicit: publish the complete row returned by Drizzle after the
86
+ write commits.
87
+
88
+ ```ts
89
+ const [avatar] = await ctx.db
90
+ .update(schema.avatars)
91
+ .set({ status: 'completed' })
92
+ .where(eq(schema.avatars.id, avatarId))
93
+ .returning()
94
+
95
+ await ctx.realtime.publish(schema.avatars, 'update', avatar)
96
+ ```
97
+
98
+ The same typed facade is available as `app.realtime`, in tRPC context, and in
99
+ queue-job and cron context. Passing the Drizzle table makes a table-name typo a
100
+ type error and constrains the record to that table's select model.
101
+
102
+ Publish after an enclosing transaction resolves, not from inside it. The full
103
+ row is required because realtime access filtering may inspect its `id`, owner,
104
+ or read-scope columns. Subscriber access checks, Redis fan-out, and replay are
105
+ applied automatically by the existing broker. When server realtime is not
106
+ configured, `realtime.enabled` is `false` and `publish()` is a no-op.
107
+
82
108
  ## Shipping TypeScript source
83
109
 
84
110
  This package publishes raw TypeScript (`exports` point at `.ts` files). Bun
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
package/src/crud.ts CHANGED
@@ -2,7 +2,7 @@ import { eq, getTableColumns, getTableName, isTable } from 'drizzle-orm'
2
2
  import { Hono } from 'hono'
3
3
 
4
4
  import type { AnyDb } from './dialect'
5
- import type { RealtimeBroker } from './realtime/index'
5
+ import type { RealtimeFacade } from './realtime/facade'
6
6
 
7
7
  import {
8
8
  checkAccess,
@@ -29,11 +29,13 @@ import {
29
29
  import { executeList, parseListParams } from './list-query'
30
30
  import { buildScopeWhere } from './scope'
31
31
 
32
- export type CrudRouterOptions = {
32
+ export type CrudRouterOptions<
33
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
34
+ > = {
33
35
  auth?: AuthSessionResolver
34
36
  access: ResolvedAccess
35
37
  idempotency?: boolean | IdempotencyConfig
36
- broker?: RealtimeBroker
38
+ realtime?: RealtimeFacade<TSchema>
37
39
  }
38
40
 
39
41
  function tableEntryForName(
@@ -63,10 +65,10 @@ async function enforce(
63
65
  export function buildCrudRouter<TSchema extends Record<string, unknown>>(
64
66
  schema: TSchema,
65
67
  db: AnyDb,
66
- options: CrudRouterOptions,
68
+ options: CrudRouterOptions<TSchema>,
67
69
  ): Hono {
68
70
  const router = new Hono()
69
- const { auth, access, broker } = options
71
+ const { auth, access, realtime } = options
70
72
  const idempotency = resolveIdempotencyConfig(options.idempotency)
71
73
 
72
74
  const scopeFor = (
@@ -243,7 +245,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
243
245
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
244
246
  const rows = await (db as any).insert(table).values(stamped).returning()
245
247
  const created = rows[0]
246
- void broker?.publish(name, 'create', created as Record<string, unknown>)
248
+ void realtime?.publish(table as never, 'create', created as never)
247
249
 
248
250
  if (idempotency && idempotencyKey) {
249
251
  await storeIdempotency(
@@ -329,7 +331,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
329
331
  if (!rows[0]) {
330
332
  return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
331
333
  }
332
- void broker?.publish(name, 'update', rows[0] as Record<string, unknown>)
334
+ void realtime?.publish(table as never, 'update', rows[0] as never)
333
335
  return c.json(rows[0])
334
336
  })
335
337
 
@@ -375,11 +377,7 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
375
377
 
376
378
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
377
379
  await (db as any).delete(table).where(eq(idCol as any, id))
378
- void broker?.publish(
379
- name,
380
- 'delete',
381
- existing[0] as Record<string, unknown>,
382
- )
380
+ void realtime?.publish(table as never, 'delete', existing[0] as never)
383
381
  return new Response(null, { status: 204 })
384
382
  })
385
383
  }
package/src/email.ts CHANGED
@@ -114,7 +114,7 @@ function createSmtpAdapter(
114
114
  sendMail(opts: Record<string, unknown>): Promise<{ messageId?: string }>
115
115
  }> | null = null
116
116
  const getTransport = () => {
117
- transportPromise ??= import(specifier).then((mod) =>
117
+ transportPromise ??= import(/* @vite-ignore */ specifier).then((mod) =>
118
118
  (mod.default ?? mod).createTransport(smtpUrl),
119
119
  )
120
120
  return transportPromise
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  } from './provision-internals'
50
50
  import { createRealtimeBroker, buildRealtimeRouter } from './realtime/index'
51
51
  import { createRedisRealtimeBroker } from './realtime/redis'
52
+ import { createRealtimeFacade, type RealtimeFacade } from './realtime/facade'
52
53
  import { deleteFileWithDerivatives } from './storage/delete'
53
54
  import { deleteFileMetaRow } from './storage/file-meta'
54
55
  import { createBucketStorages } from './storage/registry'
@@ -136,6 +137,8 @@ export type BunderstackApp<
136
137
  email: EmailFacade
137
138
  /** Job queue facade; always present — enqueue throws when jobs aren't configured. */
138
139
  jobs: JobsFacade<TJobsDefs extends JobsDefs ? TJobsDefs : Record<never, never>>
140
+ /** Typed custom row publication; enabled=false/no-op when realtime is off. */
141
+ realtime: RealtimeFacade<TSchema>
139
142
  startWorker(options?: AppStartWorkerOptions): Promise<WorkerHandle>
140
143
  /** Run a queue worker until aborted, then close the application. */
141
144
  runWorker(options?: AppRunWorkerOptions): Promise<void>
@@ -352,11 +355,12 @@ export async function createBunderstack<
352
355
  bufferSize: realtimeBufferSize,
353
356
  })
354
357
  : undefined
358
+ const realtime = createRealtimeFacade<TSchema>(broker)
355
359
  const crudRouter = buildCrudRouter(options.schema, userDb, {
356
360
  auth: authResolver,
357
361
  access: resolvedAccess,
358
362
  idempotency: options.idempotency,
359
- broker,
363
+ realtime,
360
364
  })
361
365
  const realtimeRouter = broker
362
366
  ? buildRealtimeRouter(broker, {
@@ -397,7 +401,7 @@ export async function createBunderstack<
397
401
  ? createJobRunner({
398
402
  db,
399
403
  defs: jobsDefs,
400
- ctx: { db: userDb, env, email, storage },
404
+ ctx: { db: userDb, env, email, storage, realtime },
401
405
  })
402
406
  : undefined
403
407
  const jobs = {
@@ -457,7 +461,7 @@ export async function createBunderstack<
457
461
  await runCronSlot({
458
462
  db,
459
463
  defs: jobsDefs!,
460
- ctx: { db: userDb, env, email, storage },
464
+ ctx: { db: userDb, env, email, storage, realtime },
461
465
  name,
462
466
  slot,
463
467
  now: Date.now(),
@@ -504,6 +508,7 @@ export async function createBunderstack<
504
508
  env,
505
509
  email,
506
510
  jobs,
511
+ realtime,
507
512
  req,
508
513
  }),
509
514
  })
@@ -513,7 +518,7 @@ export async function createBunderstack<
513
518
  ? buildCronRouter({
514
519
  db,
515
520
  defs: jobsDefs ?? {},
516
- ctx: { db: userDb, env, email, storage },
521
+ ctx: { db: userDb, env, email, storage, realtime },
517
522
  secret: env.BUNDERSTACK_CRON_SECRET,
518
523
  storage,
519
524
  })
@@ -544,6 +549,7 @@ export async function createBunderstack<
544
549
  router,
545
550
  env,
546
551
  email,
552
+ realtime,
547
553
  // Runtime facade is untyped (JobsRuntimeFacade); the generic-typed field
548
554
  // narrows `enqueue` per-app from the declared job defs — same relationship
549
555
  // as `userDb` above.
@@ -654,3 +660,6 @@ export type {
654
660
  } from './storage/buckets'
655
661
  // StorageFacade is declared+exported inline above.
656
662
  export type { TransformSpec } from './storage/thumbnails'
663
+
664
+ export type { RealtimeAction } from './realtime/index'
665
+ export type { RealtimeFacade, SchemaTable } from './realtime/facade'
@@ -35,6 +35,8 @@ export type JobsRuntimeFacade = {
35
35
  tick(now?: number): Promise<void>
36
36
  }
37
37
 
38
+ import type { RealtimeFacade } from '../realtime/facade'
39
+
38
40
  export type JobContext<
39
41
  TSchema extends Record<string, unknown> = Record<string, unknown>,
40
42
  TEnvResult = Record<string, unknown>,
@@ -44,6 +46,7 @@ export type JobContext<
44
46
  email: EmailFacade
45
47
  storage: StorageFacade
46
48
  jobs: JobsRuntimeFacade
49
+ realtime: RealtimeFacade<TSchema>
47
50
  }
48
51
 
49
52
  export type QueueJobDefinition<
@@ -0,0 +1,36 @@
1
+ import { getTableName, type InferSelectModel, type Table } from 'drizzle-orm'
2
+
3
+ import type { RealtimeAction, RealtimeBroker } from './index'
4
+
5
+ export type SchemaTable<TSchema extends Record<string, unknown>> = Extract<
6
+ TSchema[keyof TSchema],
7
+ Table
8
+ >
9
+
10
+ export interface RealtimeFacade<
11
+ TSchema extends Record<string, unknown> = Record<string, unknown>,
12
+ > {
13
+ readonly enabled: boolean
14
+
15
+ publish<TTable extends SchemaTable<TSchema>>(
16
+ table: TTable,
17
+ action: RealtimeAction,
18
+ record: InferSelectModel<TTable>,
19
+ ): Promise<void>
20
+ }
21
+
22
+ export function createRealtimeFacade<TSchema extends Record<string, unknown>>(
23
+ broker?: RealtimeBroker,
24
+ ): RealtimeFacade<TSchema> {
25
+ return {
26
+ enabled: broker !== undefined,
27
+ async publish(table, action, record) {
28
+ if (!broker) return
29
+ await broker.publish(
30
+ getTableName(table),
31
+ action,
32
+ record as unknown as Record<string, unknown>,
33
+ )
34
+ },
35
+ }
36
+ }
package/src/trpc.ts CHANGED
@@ -6,6 +6,7 @@ import type { AccessUser } from './access'
6
6
  import type { DbFor } from './db'
7
7
  import type { EmailFacade } from './email'
8
8
  import type { JobsRuntimeFacade } from './jobs/index'
9
+ import type { RealtimeFacade } from './realtime/facade'
9
10
 
10
11
  export type TRPCContext<
11
12
  TSchema extends Record<string, unknown>,
@@ -16,6 +17,7 @@ export type TRPCContext<
16
17
  env: TEnvResult
17
18
  email: EmailFacade
18
19
  jobs: JobsRuntimeFacade
20
+ realtime: RealtimeFacade<TSchema>
19
21
  req: Request
20
22
  }
21
23