bunderstack 0.1.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.
@@ -0,0 +1,245 @@
1
+ // packages/bunderstack/src/realtime.ts
2
+ import { Hono } from 'hono'
3
+
4
+ import {
5
+ checkAccessSync,
6
+ resolveSession,
7
+ rowMatchesScope,
8
+ type AccessUser,
9
+ type AuthSessionResolver,
10
+ type ResolvedAccess,
11
+ type ResolvedTableAccess,
12
+ } from '../access'
13
+
14
+ export type RealtimeAction = 'create' | 'update' | 'delete'
15
+
16
+ type Subscriber = {
17
+ id: string
18
+ send: (data: string) => void
19
+ user: AccessUser | null
20
+ activeOrganizationId: string | null
21
+ subscriptions: Set<string>
22
+ }
23
+
24
+ type BufferedEvent = {
25
+ eventId: number
26
+ table: string
27
+ action: RealtimeAction
28
+ record: Record<string, unknown>
29
+ }
30
+
31
+ type RealtimeContextBody = {
32
+ clientId: string
33
+ subscriptions: string[]
34
+ since?: number | null
35
+ }
36
+
37
+ export type RealtimeBroker = {
38
+ register(send: (data: string) => void): { id: string }
39
+ setContext(
40
+ id: string,
41
+ ctx: {
42
+ user: AccessUser | null
43
+ activeOrganizationId: string | null
44
+ subscriptions: Set<string>
45
+ since?: number | null
46
+ },
47
+ ): { gap: boolean } | Promise<{ gap: boolean }>
48
+ unregister(id: string): void
49
+ publish(
50
+ table: string,
51
+ action: RealtimeAction,
52
+ record: Record<string, unknown>,
53
+ ): void | Promise<void>
54
+ }
55
+
56
+ function tableEntry(
57
+ access: ResolvedAccess,
58
+ tableName: string,
59
+ ): ResolvedTableAccess | undefined {
60
+ for (const entry of access.values()) {
61
+ if (entry.tableName === tableName) return entry
62
+ }
63
+ return undefined
64
+ }
65
+
66
+ function isRecord(value: unknown): value is Record<string, unknown> {
67
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
68
+ }
69
+
70
+ function isRealtimeContextBody(value: unknown): value is RealtimeContextBody {
71
+ if (!isRecord(value)) return false
72
+ return (
73
+ typeof value.clientId === 'string' &&
74
+ Array.isArray(value.subscriptions) &&
75
+ value.subscriptions.every((item) => typeof item === 'string') &&
76
+ (value.since === undefined ||
77
+ value.since === null ||
78
+ typeof value.since === 'number')
79
+ )
80
+ }
81
+
82
+ function scopeOk(
83
+ entry: ResolvedTableAccess,
84
+ ctx: Parameters<typeof checkAccessSync>[1],
85
+ record: Record<string, unknown>,
86
+ ): boolean {
87
+ if (!entry.scope) return true
88
+ return rowMatchesScope(record, entry.scope(ctx))
89
+ }
90
+
91
+ export function buildRealtimeRouter(
92
+ broker: RealtimeBroker,
93
+ opts: { auth?: AuthSessionResolver; keepaliveMs?: number },
94
+ ): Hono {
95
+ const router = new Hono()
96
+ const keepaliveMs = opts.keepaliveMs ?? 30000
97
+
98
+ router.get('/realtime', () => {
99
+ const encoder = new TextEncoder()
100
+ let handle: { id: string }
101
+ let keepalive: ReturnType<typeof setInterval>
102
+
103
+ const stream = new ReadableStream({
104
+ start(controller) {
105
+ const send = (data: string) =>
106
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`))
107
+ handle = broker.register(send)
108
+ send(JSON.stringify({ clientId: handle.id }))
109
+ keepalive = setInterval(
110
+ () => controller.enqueue(encoder.encode(': ping\n\n')),
111
+ keepaliveMs,
112
+ )
113
+ },
114
+ cancel() {
115
+ clearInterval(keepalive)
116
+ broker.unregister(handle.id)
117
+ },
118
+ })
119
+
120
+ return new Response(stream, {
121
+ headers: {
122
+ 'Content-Type': 'text/event-stream',
123
+ 'Cache-Control': 'no-cache',
124
+ Connection: 'keep-alive',
125
+ },
126
+ })
127
+ })
128
+
129
+ router.post('/realtime', async (c) => {
130
+ const body = await c.req.json().catch(() => null)
131
+ if (!isRealtimeContextBody(body)) {
132
+ return c.json({ error: 'clientId and subscriptions required' }, 400)
133
+ }
134
+ const { user, activeOrganizationId } = await resolveSession(
135
+ opts.auth,
136
+ c.req.raw.headers,
137
+ )
138
+ const { gap } = await broker.setContext(body.clientId, {
139
+ user,
140
+ activeOrganizationId,
141
+ subscriptions: new Set(body.subscriptions),
142
+ since: body.since ?? null,
143
+ })
144
+ return c.json({ gap }, 200)
145
+ })
146
+
147
+ return router
148
+ }
149
+
150
+ export function createRealtimeBroker(opts: {
151
+ access: ResolvedAccess
152
+ bufferSize?: number
153
+ }): RealtimeBroker {
154
+ const subscribers = new Map<string, Subscriber>()
155
+ const bufferSize = opts.bufferSize ?? 1000
156
+ const buffer: BufferedEvent[] = []
157
+ let nextId = 1
158
+
159
+ // Returns true when this subscriber should receive this record (topic + access + scope).
160
+ function deliverable(
161
+ s: Subscriber,
162
+ table: string,
163
+ record: Record<string, unknown>,
164
+ id: unknown,
165
+ ): boolean {
166
+ const entry = tableEntry(opts.access, table)
167
+ if (!entry) return false
168
+ const topicMatch =
169
+ s.subscriptions.has(table) ||
170
+ (id != null && s.subscriptions.has(`${table}/${String(id)}`))
171
+ if (!topicMatch) return false
172
+ const ctx = {
173
+ user: s.user,
174
+ request: new Request('http://realtime.local'),
175
+ row: record,
176
+ session: { activeOrganizationId: s.activeOrganizationId },
177
+ }
178
+ if (typeof entry.get === 'function') return false // function get-rules unsupported on realtime v1
179
+ if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
180
+ return false
181
+ if (!scopeOk(entry, ctx, record)) return false
182
+ return true
183
+ }
184
+
185
+ return {
186
+ register(send) {
187
+ const id = crypto.randomUUID()
188
+ subscribers.set(id, {
189
+ id,
190
+ send,
191
+ user: null,
192
+ activeOrganizationId: null,
193
+ subscriptions: new Set(),
194
+ })
195
+ return { id }
196
+ },
197
+ setContext(id, ctx) {
198
+ const s = subscribers.get(id)
199
+ if (!s) return { gap: false }
200
+ s.user = ctx.user
201
+ s.activeOrganizationId = ctx.activeOrganizationId
202
+ s.subscriptions = ctx.subscriptions
203
+
204
+ const since = ctx.since ?? null
205
+ if (since == null) return { gap: false } // fresh client; current data already loaded by queries
206
+
207
+ const maxId = nextId - 1
208
+ // since ahead of anything we issued => server restarted / different epoch => full catch-up.
209
+ if (since > maxId) return { gap: true }
210
+ const oldest = buffer.length ? buffer[0]!.eventId : nextId
211
+ const gap = since < oldest - 1 // events between since and oldest were evicted
212
+ for (const e of buffer) {
213
+ if (e.eventId <= since) continue
214
+ if (!deliverable(s, e.table, e.record, e.record['id'])) continue
215
+ s.send(
216
+ JSON.stringify({
217
+ eventId: e.eventId,
218
+ action: e.action,
219
+ table: e.table,
220
+ record: e.record,
221
+ }),
222
+ )
223
+ }
224
+ return { gap }
225
+ },
226
+ unregister(id) {
227
+ subscribers.delete(id)
228
+ },
229
+ publish(table, action, record) {
230
+ const entry = tableEntry(opts.access, table)
231
+ if (!entry) return
232
+ const eventId = nextId++
233
+ buffer.push({ eventId, table, action, record })
234
+ if (buffer.length > bufferSize) buffer.shift()
235
+ const id = record['id']
236
+ const payload = JSON.stringify({ eventId, action, table, record })
237
+ for (const s of subscribers.values()) {
238
+ if (!deliverable(s, table, record, id)) continue
239
+ s.send(payload)
240
+ }
241
+ },
242
+ }
243
+ }
244
+
245
+ export const createMemoryRealtimeBroker = createRealtimeBroker
@@ -0,0 +1,204 @@
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
+ type AccessUser,
16
+ type ResolvedAccess,
17
+ type ResolvedTableAccess,
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
+ }
31
+
32
+ type Subscriber = {
33
+ id: string
34
+ send: (data: string) => void
35
+ user: AccessUser | null
36
+ activeOrganizationId: string | null
37
+ subscriptions: Set<string>
38
+ }
39
+
40
+ type WireEvent = {
41
+ eventId: number
42
+ table: string
43
+ action: RealtimeAction
44
+ record: Record<string, unknown>
45
+ }
46
+
47
+ function tableEntry(
48
+ access: ResolvedAccess,
49
+ name: string,
50
+ ): ResolvedTableAccess | undefined {
51
+ for (const entry of access.values())
52
+ if (entry.tableName === name) return entry
53
+ return undefined
54
+ }
55
+
56
+ function isRecord(value: unknown): value is Record<string, unknown> {
57
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
58
+ }
59
+
60
+ function isRealtimeAction(value: unknown): value is RealtimeAction {
61
+ return value === 'create' || value === 'update' || value === 'delete'
62
+ }
63
+
64
+ function parseWireEvent(raw: string): WireEvent | null {
65
+ try {
66
+ const value = JSON.parse(raw)
67
+ if (
68
+ !isRecord(value) ||
69
+ typeof value.eventId !== 'number' ||
70
+ typeof value.table !== 'string' ||
71
+ !isRealtimeAction(value.action) ||
72
+ !isRecord(value.record)
73
+ ) {
74
+ return null
75
+ }
76
+ return {
77
+ eventId: value.eventId,
78
+ table: value.table,
79
+ action: value.action,
80
+ record: value.record,
81
+ }
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+
87
+ export function createRedisRealtimeBroker(opts: {
88
+ access: ResolvedAccess
89
+ redis: RedisLike
90
+ bufferSize?: number
91
+ channel?: string
92
+ }): RealtimeBroker & { ready: Promise<void> } {
93
+ const subscribers = new Map<string, Subscriber>()
94
+ const bufferSize = opts.bufferSize ?? 1000
95
+ const channel = opts.channel ?? 'bunderstack:realtime'
96
+ const logKey = `${channel}:log`
97
+ const counterKey = `${channel}:seq`
98
+
99
+ function deliverable(
100
+ s: Subscriber,
101
+ table: string,
102
+ record: Record<string, unknown>,
103
+ ): boolean {
104
+ const entry = tableEntry(opts.access, table)
105
+ if (!entry) return false
106
+ const id = record['id']
107
+ const topicMatch =
108
+ s.subscriptions.has(table) ||
109
+ (id != null && s.subscriptions.has(`${table}/${String(id)}`))
110
+ if (!topicMatch) return false
111
+ const ctx = {
112
+ user: s.user,
113
+ request: new Request('http://realtime.local'),
114
+ row: record,
115
+ session: { activeOrganizationId: s.activeOrganizationId },
116
+ }
117
+ if (typeof entry.get === 'function') return false
118
+ if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
119
+ return false
120
+ if (entry.scope && !rowMatchesScope(record, entry.scope(ctx))) 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
+ // 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)
138
+
139
+ return {
140
+ ready,
141
+ register(send) {
142
+ const id = crypto.randomUUID()
143
+ subscribers.set(id, {
144
+ id,
145
+ send,
146
+ user: null,
147
+ activeOrganizationId: null,
148
+ subscriptions: new Set(),
149
+ })
150
+ return { id }
151
+ },
152
+ async setContext(id, ctx) {
153
+ const s = subscribers.get(id)
154
+ if (!s) return { gap: false }
155
+ s.user = ctx.user
156
+ s.activeOrganizationId = ctx.activeOrganizationId
157
+ s.subscriptions = ctx.subscriptions
158
+
159
+ const since = ctx.since ?? null
160
+ if (since == null) return { gap: false }
161
+
162
+ // Log is LPUSH-ed (newest first); read newest->oldest, filter id>since.
163
+ // If redis is unavailable, return { gap: true } so the client falls back
164
+ // to a full refetch rather than 500-ing the POST handler.
165
+ let raw: string[]
166
+ try {
167
+ raw = await opts.redis.lrange(logKey, 0, bufferSize - 1)
168
+ } catch {
169
+ return { gap: true }
170
+ }
171
+ const events = raw
172
+ .map(parseWireEvent)
173
+ .filter((e) => e !== null)
174
+ .filter((e) => e.eventId > since)
175
+ .sort((a, b) => a.eventId - b.eventId)
176
+
177
+ const oldestInLog = events.length ? events[0]!.eventId : since + 1
178
+ const gap = oldestInLog > since + 1
179
+ for (const e of events) {
180
+ if (deliverable(s, e.table, e.record)) s.send(JSON.stringify(e))
181
+ }
182
+ return { gap }
183
+ },
184
+ unregister(id) {
185
+ subscribers.delete(id)
186
+ },
187
+ async publish(table, action, record) {
188
+ if (!tableEntry(opts.access, table)) return
189
+ try {
190
+ const eventId = await opts.redis.incr(counterKey)
191
+ const evt: WireEvent = { eventId, table, action, record }
192
+ 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)
196
+ } catch {
197
+ // Broadcast is best-effort: a redis blip must not reject the floating
198
+ // promise (callers use `void broker?.publish(...)` with no .catch).
199
+ // Correctness self-heals: reconnecting clients use the since/gap replay
200
+ // path which issues a full refetch when events were missed.
201
+ }
202
+ },
203
+ }
204
+ }
@@ -0,0 +1,4 @@
1
+ export {
2
+ bunderstackFiles,
3
+ bunderstackIdempotency,
4
+ } from './internal-tables'
package/src/scope.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { and, eq, getTableColumns, inArray, type SQL } from 'drizzle-orm'
2
+
3
+ import type { ScopeMap } from './access'
4
+
5
+ export function buildScopeWhere(
6
+ table: Parameters<typeof getTableColumns>[0],
7
+ scope: ScopeMap,
8
+ ): SQL | undefined {
9
+ const columns = getTableColumns(table)
10
+ const conditions: SQL[] = []
11
+ for (const [name, value] of Object.entries(scope)) {
12
+ const col = columns[name]
13
+ if (!col) continue
14
+ conditions.push(Array.isArray(value) ? inArray(col, value) : eq(col, value))
15
+ }
16
+ return conditions.length ? and(...conditions) : undefined
17
+ }