bunderstack 0.16.0 → 0.17.0-beta.2
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 +25 -138
- package/package.json +24 -14
- package/src/access.ts +6 -21
- package/src/api/builder.ts +52 -0
- package/src/api/context.ts +83 -0
- package/src/api/crud-router.ts +386 -0
- package/src/api/openapi.ts +184 -0
- package/src/api/realtime-router.ts +75 -0
- package/src/api/registry.ts +338 -0
- package/src/api/router.ts +34 -0
- package/src/api/storage-router.ts +224 -0
- package/src/api/types.ts +105 -0
- package/src/auth.ts +5 -0
- package/src/blueprint.ts +88 -105
- package/src/config.ts +57 -39
- package/src/crud-operations.ts +478 -0
- package/src/dialect.ts +1 -1
- package/src/email/smtp.ts +1 -1
- package/src/env.ts +19 -13
- package/src/errors.ts +98 -26
- package/src/handler.ts +16 -44
- package/src/index.ts +248 -247
- package/src/jobs/define.ts +8 -10
- package/src/jobs/queue.ts +6 -2
- package/src/jobs/worker.ts +4 -1
- package/src/list-query.ts +50 -133
- package/src/manifest.ts +84 -87
- package/src/rate-limit.ts +1 -1
- package/src/realtime/facade.ts +36 -14
- package/src/realtime/filter.ts +81 -0
- package/src/realtime/heartbeat.ts +80 -0
- package/src/realtime/publisher.ts +46 -0
- package/src/standard-schema.ts +59 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/operations.ts +398 -0
- package/src/crud.ts +0 -400
- package/src/realtime/index.ts +0 -242
- package/src/realtime/redis.ts +0 -219
- package/src/routes.ts +0 -137
- package/src/storage/router.ts +0 -531
- package/src/trpc.ts +0 -57
package/src/realtime/redis.ts
DELETED
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
import type { RealtimeAction, RealtimeBroker } from './index'
|
|
2
|
-
|
|
3
|
-
// packages/bunderstack/src/realtime-redis.ts
|
|
4
|
-
//
|
|
5
|
-
// Redis-backed realtime broker: cross-instance fan-out + persistent replay log.
|
|
6
|
-
//
|
|
7
|
-
// Fan-out model: every instance SUBSCRIBEs one channel. publish() INCRs a global
|
|
8
|
-
// counter (monotonic eventId across instances/restarts), LPUSH+LTRIM a capped log
|
|
9
|
-
// for replay, then PUBLISHes. Redis delivers the message to ALL subscribers
|
|
10
|
-
// including the publisher, so local delivery happens uniformly inside the channel
|
|
11
|
-
// listener — never directly in publish() — to avoid double-delivery.
|
|
12
|
-
import {
|
|
13
|
-
checkAccessSync,
|
|
14
|
-
rowMatchesScope,
|
|
15
|
-
tableEntryForName,
|
|
16
|
-
type AccessUser,
|
|
17
|
-
type ResolvedAccess,
|
|
18
|
-
} from '../access'
|
|
19
|
-
|
|
20
|
-
export type RedisLike = {
|
|
21
|
-
incr(key: string): Promise<number>
|
|
22
|
-
publish(channel: string, message: string): Promise<unknown>
|
|
23
|
-
subscribe(
|
|
24
|
-
channel: string,
|
|
25
|
-
listener: (message: string) => void,
|
|
26
|
-
): Promise<unknown>
|
|
27
|
-
lpush(key: string, value: string): Promise<unknown>
|
|
28
|
-
ltrim(key: string, start: number, stop: number): Promise<unknown>
|
|
29
|
-
lrange(key: string, start: number, stop: number): Promise<string[]>
|
|
30
|
-
close?(): void | Promise<void>
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
type Subscriber = {
|
|
34
|
-
id: string
|
|
35
|
-
send: (data: string) => void
|
|
36
|
-
user: AccessUser | null
|
|
37
|
-
activeOrganizationId: string | null
|
|
38
|
-
subscriptions: Set<string>
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
type WireEvent = {
|
|
42
|
-
eventId: number
|
|
43
|
-
table: string
|
|
44
|
-
action: RealtimeAction
|
|
45
|
-
record: Record<string, unknown>
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function isRealtimeAction(value: unknown): value is RealtimeAction {
|
|
53
|
-
return value === 'create' || value === 'update' || value === 'delete'
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function parseWireEvent(raw: string): WireEvent | null {
|
|
57
|
-
try {
|
|
58
|
-
const value = JSON.parse(raw)
|
|
59
|
-
if (
|
|
60
|
-
!isRecord(value) ||
|
|
61
|
-
typeof value.eventId !== 'number' ||
|
|
62
|
-
typeof value.table !== 'string' ||
|
|
63
|
-
!isRealtimeAction(value.action) ||
|
|
64
|
-
!isRecord(value.record)
|
|
65
|
-
) {
|
|
66
|
-
return null
|
|
67
|
-
}
|
|
68
|
-
return {
|
|
69
|
-
eventId: value.eventId,
|
|
70
|
-
table: value.table,
|
|
71
|
-
action: value.action,
|
|
72
|
-
record: value.record,
|
|
73
|
-
}
|
|
74
|
-
} catch {
|
|
75
|
-
return null
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function createRedisRealtimeBroker(opts: {
|
|
80
|
-
access: ResolvedAccess
|
|
81
|
-
/** A factory keeps Redis completely cold until SSE or a publish needs it. */
|
|
82
|
-
redis: RedisLike | (() => RedisLike)
|
|
83
|
-
bufferSize?: number
|
|
84
|
-
channel?: string
|
|
85
|
-
}): RealtimeBroker {
|
|
86
|
-
const subscribers = new Map<string, Subscriber>()
|
|
87
|
-
const bufferSize = opts.bufferSize ?? 1000
|
|
88
|
-
const channel = opts.channel ?? 'bunderstack:realtime'
|
|
89
|
-
const logKey = `${channel}:log`
|
|
90
|
-
const counterKey = `${channel}:seq`
|
|
91
|
-
let redis: RedisLike | undefined
|
|
92
|
-
|
|
93
|
-
const getRedis = () => {
|
|
94
|
-
redis ??= typeof opts.redis === 'function' ? opts.redis() : opts.redis
|
|
95
|
-
return redis
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function deliverable(
|
|
99
|
-
s: Subscriber,
|
|
100
|
-
table: string,
|
|
101
|
-
record: Record<string, unknown>,
|
|
102
|
-
): boolean {
|
|
103
|
-
const entry = tableEntryForName(opts.access, table)
|
|
104
|
-
if (!entry) return false
|
|
105
|
-
const id = record['id']
|
|
106
|
-
const topicMatch =
|
|
107
|
-
s.subscriptions.has(table) ||
|
|
108
|
-
(id != null && s.subscriptions.has(`${table}/${String(id)}`))
|
|
109
|
-
if (!topicMatch) return false
|
|
110
|
-
const ctx = {
|
|
111
|
-
user: s.user,
|
|
112
|
-
request: new Request('http://realtime.local'),
|
|
113
|
-
row: record,
|
|
114
|
-
session: { activeOrganizationId: s.activeOrganizationId },
|
|
115
|
-
}
|
|
116
|
-
if (typeof entry.get === 'function') return false
|
|
117
|
-
if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
|
|
118
|
-
return false
|
|
119
|
-
if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx)))
|
|
120
|
-
return false
|
|
121
|
-
return true
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function fanOut(evt: WireEvent) {
|
|
125
|
-
const payload = JSON.stringify(evt)
|
|
126
|
-
for (const s of subscribers.values()) {
|
|
127
|
-
if (deliverable(s, evt.table, evt.record)) s.send(payload)
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
let started: Promise<void> | undefined
|
|
132
|
-
let closed = false
|
|
133
|
-
|
|
134
|
-
const start = () => {
|
|
135
|
-
if (closed)
|
|
136
|
-
return Promise.reject(
|
|
137
|
-
new Error('[bunderstack] realtime broker is closed'),
|
|
138
|
-
)
|
|
139
|
-
started ??= getRedis()
|
|
140
|
-
.subscribe(channel, (message) => {
|
|
141
|
-
const evt = parseWireEvent(message)
|
|
142
|
-
if (evt) fanOut(evt)
|
|
143
|
-
})
|
|
144
|
-
.then(() => undefined)
|
|
145
|
-
return started
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
start,
|
|
150
|
-
async close() {
|
|
151
|
-
closed = true
|
|
152
|
-
subscribers.clear()
|
|
153
|
-
await redis?.close?.()
|
|
154
|
-
},
|
|
155
|
-
register(send) {
|
|
156
|
-
const id = crypto.randomUUID()
|
|
157
|
-
subscribers.set(id, {
|
|
158
|
-
id,
|
|
159
|
-
send,
|
|
160
|
-
user: null,
|
|
161
|
-
activeOrganizationId: null,
|
|
162
|
-
subscriptions: new Set(),
|
|
163
|
-
})
|
|
164
|
-
return { id }
|
|
165
|
-
},
|
|
166
|
-
async setContext(id, ctx) {
|
|
167
|
-
const s = subscribers.get(id)
|
|
168
|
-
if (!s) return { gap: false }
|
|
169
|
-
s.user = ctx.user
|
|
170
|
-
s.activeOrganizationId = ctx.activeOrganizationId
|
|
171
|
-
s.subscriptions = ctx.subscriptions
|
|
172
|
-
|
|
173
|
-
const since = ctx.since ?? null
|
|
174
|
-
if (since == null) return { gap: false }
|
|
175
|
-
|
|
176
|
-
// Log is LPUSH-ed (newest first); read newest->oldest, filter id>since.
|
|
177
|
-
// If redis is unavailable, return { gap: true } so the client falls back
|
|
178
|
-
// to a full refetch rather than 500-ing the POST handler.
|
|
179
|
-
let raw: string[]
|
|
180
|
-
try {
|
|
181
|
-
raw = await getRedis().lrange(logKey, 0, bufferSize - 1)
|
|
182
|
-
} catch {
|
|
183
|
-
return { gap: true }
|
|
184
|
-
}
|
|
185
|
-
const events = raw
|
|
186
|
-
.map(parseWireEvent)
|
|
187
|
-
.filter((e) => e !== null)
|
|
188
|
-
.filter((e) => e.eventId > since)
|
|
189
|
-
.sort((a, b) => a.eventId - b.eventId)
|
|
190
|
-
|
|
191
|
-
const oldestInLog = events.length ? events[0]!.eventId : since + 1
|
|
192
|
-
const gap = oldestInLog > since + 1
|
|
193
|
-
for (const e of events) {
|
|
194
|
-
if (deliverable(s, e.table, e.record)) s.send(JSON.stringify(e))
|
|
195
|
-
}
|
|
196
|
-
return { gap }
|
|
197
|
-
},
|
|
198
|
-
unregister(id) {
|
|
199
|
-
subscribers.delete(id)
|
|
200
|
-
},
|
|
201
|
-
async publish(table, action, record) {
|
|
202
|
-
if (!tableEntryForName(opts.access, table)) return
|
|
203
|
-
try {
|
|
204
|
-
const client = getRedis()
|
|
205
|
-
const eventId = await client.incr(counterKey)
|
|
206
|
-
const evt: WireEvent = { eventId, table, action, record }
|
|
207
|
-
const msg = JSON.stringify(evt)
|
|
208
|
-
await client.lpush(logKey, msg)
|
|
209
|
-
await client.ltrim(logKey, 0, bufferSize - 1)
|
|
210
|
-
await client.publish(channel, msg)
|
|
211
|
-
} catch {
|
|
212
|
-
// Broadcast is best-effort: a redis blip must not reject the floating
|
|
213
|
-
// promise (callers use `void broker?.publish(...)` with no .catch).
|
|
214
|
-
// Correctness self-heals: reconnecting clients use the since/gap replay
|
|
215
|
-
// path which issues a full refetch when events were missed.
|
|
216
|
-
}
|
|
217
|
-
},
|
|
218
|
-
}
|
|
219
|
-
}
|
package/src/routes.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
// src/routes.ts — mounting user-supplied Hono routes inside the app.
|
|
2
|
-
|
|
3
|
-
/** A route as Hono reports it on `app.routes`. */
|
|
4
|
-
export type DeclaredRoute = { method: string; path: string }
|
|
5
|
-
|
|
6
|
-
const RESERVED_EXACT = ['/health', '/api/health', '/api/realtime'] as const
|
|
7
|
-
|
|
8
|
-
const RESERVED_PREFIXES = [
|
|
9
|
-
'/api/auth/',
|
|
10
|
-
'/api/trpc/',
|
|
11
|
-
'/api/files/',
|
|
12
|
-
'/files/',
|
|
13
|
-
] as const
|
|
14
|
-
|
|
15
|
-
/** The first path segment under `/api/`, or undefined when not under it. */
|
|
16
|
-
function apiSegment(path: string): string | undefined {
|
|
17
|
-
if (!path.startsWith('/api/')) return undefined
|
|
18
|
-
return path.slice('/api/'.length).split('/')[0]
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function collisionFor(
|
|
22
|
-
route: DeclaredRoute,
|
|
23
|
-
tableNames: readonly string[],
|
|
24
|
-
): string | undefined {
|
|
25
|
-
const { path } = route
|
|
26
|
-
if (RESERVED_EXACT.includes(path as (typeof RESERVED_EXACT)[number])) {
|
|
27
|
-
return `it is reserved by bunderstack`
|
|
28
|
-
}
|
|
29
|
-
for (const prefix of RESERVED_PREFIXES) {
|
|
30
|
-
if (path.startsWith(prefix)) {
|
|
31
|
-
return `"${prefix}*" is reserved by bunderstack`
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
const segment = apiSegment(path)
|
|
35
|
-
if (segment === undefined) return undefined
|
|
36
|
-
if (segment === '*' || segment.startsWith(':')) {
|
|
37
|
-
return `a parameter or wildcard here would shadow every generated CRUD route`
|
|
38
|
-
}
|
|
39
|
-
if (tableNames.includes(segment)) {
|
|
40
|
-
return `it collides with the generated CRUD route for table "${segment}"`
|
|
41
|
-
}
|
|
42
|
-
return undefined
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Throws when any declared route would collide with a bunderstack route.
|
|
47
|
-
*
|
|
48
|
-
* Custom routes are registered before the built-ins, so a collision silently
|
|
49
|
-
* shadows core behaviour — including authentication. Failing at construction is
|
|
50
|
-
* the cheapest place to find out.
|
|
51
|
-
*/
|
|
52
|
-
export function validateCustomRoutes(
|
|
53
|
-
routes: readonly DeclaredRoute[],
|
|
54
|
-
tableNames: readonly string[],
|
|
55
|
-
): void {
|
|
56
|
-
const problems: string[] = []
|
|
57
|
-
for (const route of routes) {
|
|
58
|
-
const reason = collisionFor(route, tableNames)
|
|
59
|
-
if (reason) {
|
|
60
|
-
problems.push(` ${route.method} ${route.path} — ${reason}`)
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
if (problems.length === 0) return
|
|
64
|
-
throw new Error(
|
|
65
|
-
`[bunderstack] routes: ${problems.length} route(s) collide with bunderstack's own:\n${problems.join('\n')}\nChoose different paths.`,
|
|
66
|
-
)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
import type { Hono } from 'hono'
|
|
70
|
-
|
|
71
|
-
import type { AccessUser, AuthSessionResolver } from './access'
|
|
72
|
-
import type { DbFor } from './db'
|
|
73
|
-
import type { EmailFacade } from './email'
|
|
74
|
-
import type { JobsRuntimeFacade } from './jobs/define'
|
|
75
|
-
import type { RealtimeFacade } from './realtime/facade'
|
|
76
|
-
import type { AuthInstance, StorageFacade } from './index'
|
|
77
|
-
|
|
78
|
-
import { resolveAccessUser, resolveSession } from './access'
|
|
79
|
-
|
|
80
|
-
export type RouteContext<
|
|
81
|
-
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
82
|
-
TEnvResult = Record<string, unknown>,
|
|
83
|
-
> = {
|
|
84
|
-
db: DbFor<TSchema>
|
|
85
|
-
env: TEnvResult
|
|
86
|
-
storage: StorageFacade
|
|
87
|
-
email: EmailFacade
|
|
88
|
-
jobs: JobsRuntimeFacade
|
|
89
|
-
realtime: RealtimeFacade<TSchema>
|
|
90
|
-
auth: AuthInstance
|
|
91
|
-
/** Resolve the caller's session. Costs an auth round-trip; call only when needed. */
|
|
92
|
-
getSession(
|
|
93
|
-
request: Request,
|
|
94
|
-
): Promise<{ user: AccessUser | null; activeOrganizationId: string | null }>
|
|
95
|
-
/** Convenience wrapper over getSession when the organization is irrelevant. */
|
|
96
|
-
getUser(request: Request): Promise<AccessUser | null>
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Alias mirroring the JobContext / BunderstackJobContext pair. */
|
|
100
|
-
export type BunderstackRouteContext<
|
|
101
|
-
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
102
|
-
TEnvResult = Record<string, unknown>,
|
|
103
|
-
> = RouteContext<TSchema, TEnvResult>
|
|
104
|
-
|
|
105
|
-
export type RoutesBuilder<
|
|
106
|
-
TSchema extends Record<string, unknown> = Record<string, unknown>,
|
|
107
|
-
TEnvResult = Record<string, unknown>,
|
|
108
|
-
> = (ctx: RouteContext<TSchema, TEnvResult>) => Hono
|
|
109
|
-
|
|
110
|
-
export function createRouteContext<
|
|
111
|
-
TSchema extends Record<string, unknown>,
|
|
112
|
-
TEnvResult,
|
|
113
|
-
>(deps: {
|
|
114
|
-
db: DbFor<TSchema>
|
|
115
|
-
env: TEnvResult
|
|
116
|
-
storage: StorageFacade
|
|
117
|
-
email: EmailFacade
|
|
118
|
-
jobs: JobsRuntimeFacade
|
|
119
|
-
realtime: RealtimeFacade<TSchema>
|
|
120
|
-
auth: AuthInstance
|
|
121
|
-
authResolver: AuthSessionResolver | undefined
|
|
122
|
-
}): RouteContext<TSchema, TEnvResult> {
|
|
123
|
-
return {
|
|
124
|
-
db: deps.db,
|
|
125
|
-
env: deps.env,
|
|
126
|
-
storage: deps.storage,
|
|
127
|
-
email: deps.email,
|
|
128
|
-
jobs: deps.jobs,
|
|
129
|
-
realtime: deps.realtime,
|
|
130
|
-
auth: deps.auth,
|
|
131
|
-
// Lazy on purpose: a webhook has no session, and resolving one eagerly
|
|
132
|
-
// would spend an auth round-trip per request on a value nobody reads.
|
|
133
|
-
getSession: (request) => resolveSession(deps.authResolver, request.headers),
|
|
134
|
-
getUser: (request) => resolveAccessUser(deps.authResolver, request.headers),
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|