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 +26 -0
- package/package.json +1 -1
- package/src/crud.ts +10 -12
- package/src/email.ts +1 -1
- package/src/index.ts +13 -4
- package/src/jobs/define.ts +3 -0
- package/src/realtime/facade.ts +36 -0
- package/src/trpc.ts +2 -0
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.
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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'
|
package/src/jobs/define.ts
CHANGED
|
@@ -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
|
|