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/src/crud.ts DELETED
@@ -1,400 +0,0 @@
1
- import { eq, getTableColumns, getTableName, isTable } from 'drizzle-orm'
2
- import { Hono } from 'hono'
3
-
4
- import type { AnyDb } from './dialect'
5
- import type { RealtimeFacade } from './realtime/facade'
6
-
7
- import {
8
- checkAccess,
9
- resolveSession,
10
- rowMatchesScope,
11
- stampScope,
12
- sanitizeWriteBody,
13
- type AccessUser,
14
- type AuthSessionResolver,
15
- type CrudOperation,
16
- type ResolvedAccess,
17
- tableEntryForName,
18
- type ResolvedTableAccess,
19
- type ScopeMap,
20
- type ScopeResolver,
21
- type AccessContext,
22
- } from './access'
23
- import { ErrorCode, apiError, ListQueryError } from './errors'
24
- import {
25
- lookupIdempotency,
26
- resolveIdempotencyConfig,
27
- storeIdempotency,
28
- type IdempotencyConfig,
29
- } from './idempotency'
30
- import { executeList, parseListParams } from './list-query'
31
- import { buildScopeWhere } from './scope'
32
-
33
- export type CrudRouterOptions<
34
- TSchema extends Record<string, unknown> = Record<string, unknown>,
35
- > = {
36
- auth?: AuthSessionResolver
37
- access: ResolvedAccess
38
- idempotency?: boolean | IdempotencyConfig
39
- realtime?: RealtimeFacade<TSchema>
40
- }
41
-
42
-
43
- function isRecord(value: unknown): value is Record<string, unknown> {
44
- return value !== null && typeof value === 'object' && !Array.isArray(value)
45
- }
46
-
47
- async function enforce(
48
- operation: CrudOperation,
49
- access: ResolvedTableAccess,
50
- ctx: Parameters<typeof checkAccess>[1],
51
- ) {
52
- const rule = access[operation]
53
- const result = await checkAccess(rule, ctx, access.ownerColumn)
54
- return result
55
- }
56
-
57
- export function buildCrudRouter<TSchema extends Record<string, unknown>>(
58
- schema: TSchema,
59
- db: AnyDb,
60
- options: CrudRouterOptions<TSchema>,
61
- ): Hono {
62
- const router = new Hono()
63
- const { auth, access, realtime } = options
64
- const idempotency = resolveIdempotencyConfig(options.idempotency)
65
-
66
- const scopeFor = (
67
- resolver: ScopeResolver | undefined,
68
- ctx: AccessContext,
69
- ): ScopeMap | undefined => (resolver ? resolver(ctx) : undefined)
70
-
71
- for (const table of Object.values(schema)) {
72
- if (!isTable(table)) continue
73
-
74
- const name = getTableName(table)
75
- const tableAccess = tableEntryForName(access, name)
76
- if (!tableAccess?.enabled) continue
77
-
78
- const idCol = getTableColumns(table)['id']
79
- if (!idCol) continue
80
-
81
- router.get(`/${name}`, async (c) => {
82
- const { user, activeOrganizationId } = await resolveSession(
83
- auth,
84
- c.req.raw.headers,
85
- )
86
- const session = { activeOrganizationId }
87
- const denied = await enforce('list', tableAccess, {
88
- user,
89
- session,
90
- request: c.req.raw,
91
- })
92
- if (!denied.allowed) {
93
- return apiError(
94
- c,
95
- ErrorCode.FORBIDDEN,
96
- 'Forbidden',
97
- denied.status === 401 ? 401 : 403,
98
- )
99
- }
100
-
101
- try {
102
- const params = parseListParams(new URL(c.req.url), tableAccess)
103
- const scope = scopeFor(tableAccess.readScope, {
104
- user,
105
- session,
106
- request: c.req.raw,
107
- })
108
- const scopeWhere = scope ? buildScopeWhere(table, scope) : undefined
109
- const result = await executeList(
110
- db,
111
- table,
112
- tableAccess,
113
- params,
114
- idCol,
115
- scopeWhere,
116
- )
117
- return c.json(result)
118
- } catch (err) {
119
- if (err instanceof ListQueryError) {
120
- return apiError(c, err.code, err.message, 400, err.details)
121
- }
122
- throw err
123
- }
124
- })
125
-
126
- router.get(`/${name}/:id`, async (c) => {
127
- const { user, activeOrganizationId } = await resolveSession(
128
- auth,
129
- c.req.raw.headers,
130
- )
131
- const session = { activeOrganizationId }
132
- const rawId = c.req.param('id')
133
- const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
134
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
135
- const rows = await (db as any)
136
- .select()
137
- .from(table)
138
- .where(eq(idCol as any, id))
139
- if (!rows[0]) {
140
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
141
- }
142
-
143
- const denied = await enforce('get', tableAccess, {
144
- user,
145
- session,
146
- request: c.req.raw,
147
- row: rows[0] as Record<string, unknown>,
148
- })
149
- if (!denied.allowed) {
150
- return apiError(
151
- c,
152
- ErrorCode.FORBIDDEN,
153
- 'Forbidden',
154
- denied.status === 401 ? 401 : 403,
155
- )
156
- }
157
-
158
- const scope = scopeFor(tableAccess.readScope, {
159
- user,
160
- session,
161
- request: c.req.raw,
162
- })
163
- if (
164
- scope &&
165
- !rowMatchesScope(rows[0] as Record<string, unknown>, scope)
166
- ) {
167
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
168
- }
169
-
170
- return c.json(rows[0])
171
- })
172
-
173
- router.post(`/${name}`, async (c) => {
174
- const { user, activeOrganizationId } = await resolveSession(
175
- auth,
176
- c.req.raw.headers,
177
- )
178
- const session = { activeOrganizationId }
179
- const denied = await enforce('create', tableAccess, {
180
- user,
181
- session,
182
- request: c.req.raw,
183
- })
184
- if (!denied.allowed) {
185
- return apiError(
186
- c,
187
- ErrorCode.FORBIDDEN,
188
- 'Forbidden',
189
- denied.status === 401 ? 401 : 403,
190
- )
191
- }
192
-
193
- const rawBody = await c.req.text()
194
- let body: unknown
195
- try {
196
- body = rawBody ? JSON.parse(rawBody) : null
197
- } catch {
198
- return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON', 400)
199
- }
200
- if (!isRecord(body)) {
201
- return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON body', 400)
202
- }
203
-
204
- const idempotencyKey = c.req.header('Idempotency-Key')?.trim()
205
- if (idempotency && idempotencyKey) {
206
- const lookup = await lookupIdempotency(
207
- db,
208
- name,
209
- idempotencyKey,
210
- rawBody,
211
- idempotency,
212
- )
213
- if (lookup.type === 'conflict') {
214
- return apiError(
215
- c,
216
- ErrorCode.IDEMPOTENCY_CONFLICT,
217
- 'Idempotency key reused with different body',
218
- 409,
219
- )
220
- }
221
- if (lookup.type === 'replay') {
222
- return new Response(lookup.response, {
223
- status: lookup.status,
224
- headers: {
225
- 'Content-Type': 'application/json',
226
- 'Idempotency-Replayed': 'true',
227
- },
228
- })
229
- }
230
- }
231
-
232
- const values = sanitizeWriteBody(
233
- body,
234
- tableAccess,
235
- 'create',
236
- user?.id ?? null,
237
- )
238
-
239
- const scope = scopeFor(tableAccess.writeScope, {
240
- user,
241
- session,
242
- request: c.req.raw,
243
- body: body as Record<string, unknown>,
244
- })
245
- const stamped = scope ? stampScope(values, scope) : values
246
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
247
- const rows = await (db as any).insert(table).values(stamped).returning()
248
- const created = rows[0]
249
- void realtime?.publish(table as never, 'create', created as never)
250
-
251
- if (idempotency && idempotencyKey) {
252
- await storeIdempotency(
253
- db,
254
- name,
255
- idempotencyKey,
256
- rawBody,
257
- 201,
258
- created,
259
- idempotency,
260
- )
261
- }
262
-
263
- return c.json(created, 201)
264
- })
265
-
266
- router.patch(`/${name}/:id`, async (c) => {
267
- const { user, activeOrganizationId } = await resolveSession(
268
- auth,
269
- c.req.raw.headers,
270
- )
271
- const session = { activeOrganizationId }
272
- const rawId = c.req.param('id')
273
- const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
274
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
275
- const existing = await (db as any)
276
- .select()
277
- .from(table)
278
- .where(eq(idCol as any, id))
279
- if (!existing[0]) {
280
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
281
- }
282
-
283
- const readScope = scopeFor(tableAccess.readScope, {
284
- user,
285
- session,
286
- request: c.req.raw,
287
- })
288
- if (
289
- readScope &&
290
- !rowMatchesScope(existing[0] as Record<string, unknown>, readScope)
291
- ) {
292
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
293
- }
294
-
295
- const denied = await enforce('update', tableAccess, {
296
- user,
297
- session,
298
- request: c.req.raw,
299
- row: existing[0] as Record<string, unknown>,
300
- })
301
- if (!denied.allowed) {
302
- return apiError(
303
- c,
304
- ErrorCode.FORBIDDEN,
305
- 'Forbidden',
306
- denied.status === 401 ? 401 : 403,
307
- )
308
- }
309
-
310
- let body: unknown
311
- try {
312
- body = await c.req.json()
313
- } catch {
314
- return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON', 400)
315
- }
316
- if (!isRecord(body)) {
317
- return apiError(c, ErrorCode.VALIDATION_ERROR, 'Invalid JSON body', 400)
318
- }
319
-
320
- const values = sanitizeWriteBody(
321
- body,
322
- tableAccess,
323
- 'update',
324
- user?.id ?? null,
325
- )
326
-
327
- const writeScope = scopeFor(tableAccess.writeScope, {
328
- user,
329
- session,
330
- request: c.req.raw,
331
- body: body as Record<string, unknown>,
332
- })
333
- const stamped = writeScope ? stampScope(values, writeScope) : values
334
-
335
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
336
- const rows = await (db as any)
337
- .update(table)
338
- .set(stamped)
339
- .where(eq(idCol as any, id))
340
- .returning()
341
- if (!rows[0]) {
342
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
343
- }
344
- void realtime?.publish(table as never, 'update', rows[0] as never)
345
- return c.json(rows[0])
346
- })
347
-
348
- router.delete(`/${name}/:id`, async (c) => {
349
- const { user, activeOrganizationId } = await resolveSession(
350
- auth,
351
- c.req.raw.headers,
352
- )
353
- const session = { activeOrganizationId }
354
- const rawId = c.req.param('id')
355
- const id = isNaN(Number(rawId)) ? rawId : Number(rawId)
356
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
357
- const existing = await (db as any)
358
- .select()
359
- .from(table)
360
- .where(eq(idCol as any, id))
361
- if (!existing[0]) {
362
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
363
- }
364
-
365
- const scope = scopeFor(tableAccess.readScope, {
366
- user,
367
- session,
368
- request: c.req.raw,
369
- })
370
- if (
371
- scope &&
372
- !rowMatchesScope(existing[0] as Record<string, unknown>, scope)
373
- ) {
374
- return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
375
- }
376
-
377
- const denied = await enforce('delete', tableAccess, {
378
- user,
379
- session,
380
- request: c.req.raw,
381
- row: existing[0] as Record<string, unknown>,
382
- })
383
- if (!denied.allowed) {
384
- return apiError(
385
- c,
386
- ErrorCode.FORBIDDEN,
387
- 'Forbidden',
388
- denied.status === 401 ? 401 : 403,
389
- )
390
- }
391
-
392
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
393
- await (db as any).delete(table).where(eq(idCol as any, id))
394
- void realtime?.publish(table as never, 'delete', existing[0] as never)
395
- return new Response(null, { status: 204 })
396
- })
397
- }
398
-
399
- return router
400
- }
@@ -1,242 +0,0 @@
1
- // packages/bunderstack/src/realtime.ts
2
- import { Hono } from 'hono'
3
-
4
- import {
5
- checkAccessSync,
6
- resolveSession,
7
- rowMatchesScope,
8
- tableEntryForName,
9
- type AccessUser,
10
- type AuthSessionResolver,
11
- type ResolvedAccess,
12
- type ResolvedTableAccess,
13
- } from '../access'
14
-
15
- export type RealtimeAction = 'create' | 'update' | 'delete'
16
-
17
- type Subscriber = {
18
- id: string
19
- send: (data: string) => void
20
- user: AccessUser | null
21
- activeOrganizationId: string | null
22
- subscriptions: Set<string>
23
- }
24
-
25
- type BufferedEvent = {
26
- eventId: number
27
- table: string
28
- action: RealtimeAction
29
- record: Record<string, unknown>
30
- }
31
-
32
- type RealtimeContextBody = {
33
- clientId: string
34
- subscriptions: string[]
35
- since?: number | null
36
- }
37
-
38
- export type RealtimeBroker = {
39
- start(): Promise<void>
40
- close(): Promise<void>
41
- register(send: (data: string) => void): { id: string }
42
- setContext(
43
- id: string,
44
- ctx: {
45
- user: AccessUser | null
46
- activeOrganizationId: string | null
47
- subscriptions: Set<string>
48
- since?: number | null
49
- },
50
- ): { gap: boolean } | Promise<{ gap: boolean }>
51
- unregister(id: string): void
52
- publish(
53
- table: string,
54
- action: RealtimeAction,
55
- record: Record<string, unknown>,
56
- ): void | Promise<void>
57
- }
58
-
59
-
60
- function isRecord(value: unknown): value is Record<string, unknown> {
61
- return value !== null && typeof value === 'object' && !Array.isArray(value)
62
- }
63
-
64
- function isRealtimeContextBody(value: unknown): value is RealtimeContextBody {
65
- if (!isRecord(value)) return false
66
- return (
67
- typeof value.clientId === 'string' &&
68
- Array.isArray(value.subscriptions) &&
69
- value.subscriptions.every((item) => typeof item === 'string') &&
70
- (value.since === undefined ||
71
- value.since === null ||
72
- typeof value.since === 'number')
73
- )
74
- }
75
-
76
- function scopeOk(
77
- entry: ResolvedTableAccess,
78
- ctx: Parameters<typeof checkAccessSync>[1],
79
- record: Record<string, unknown>,
80
- ): boolean {
81
- if (!entry.readScope) return true
82
- return rowMatchesScope(record, entry.readScope(ctx))
83
- }
84
-
85
- export function buildRealtimeRouter(
86
- broker: RealtimeBroker,
87
- opts: { auth?: AuthSessionResolver; keepaliveMs?: number },
88
- ): Hono {
89
- const router = new Hono()
90
- const keepaliveMs = opts.keepaliveMs ?? 30000
91
-
92
- router.get('/realtime', () => {
93
- const encoder = new TextEncoder()
94
- let handle: { id: string }
95
- let keepalive: ReturnType<typeof setInterval>
96
-
97
- const stream = new ReadableStream({
98
- async start(controller) {
99
- await broker.start()
100
- const send = (data: string) =>
101
- controller.enqueue(encoder.encode(`data: ${data}\n\n`))
102
- handle = broker.register(send)
103
- send(JSON.stringify({ clientId: handle.id }))
104
- keepalive = setInterval(
105
- () => controller.enqueue(encoder.encode(': ping\n\n')),
106
- keepaliveMs,
107
- )
108
- },
109
- cancel() {
110
- clearInterval(keepalive)
111
- broker.unregister(handle.id)
112
- },
113
- })
114
-
115
- return new Response(stream, {
116
- headers: {
117
- 'Content-Type': 'text/event-stream',
118
- 'Cache-Control': 'no-cache',
119
- Connection: 'keep-alive',
120
- },
121
- })
122
- })
123
-
124
- router.post('/realtime', async (c) => {
125
- const body = await c.req.json().catch(() => null)
126
- if (!isRealtimeContextBody(body)) {
127
- return c.json({ error: 'clientId and subscriptions required' }, 400)
128
- }
129
- const { user, activeOrganizationId } = await resolveSession(
130
- opts.auth,
131
- c.req.raw.headers,
132
- )
133
- const { gap } = await broker.setContext(body.clientId, {
134
- user,
135
- activeOrganizationId,
136
- subscriptions: new Set(body.subscriptions),
137
- since: body.since ?? null,
138
- })
139
- return c.json({ gap }, 200)
140
- })
141
-
142
- return router
143
- }
144
-
145
- export function createRealtimeBroker(opts: {
146
- access: ResolvedAccess
147
- bufferSize?: number
148
- }): RealtimeBroker {
149
- const subscribers = new Map<string, Subscriber>()
150
- const bufferSize = opts.bufferSize ?? 1000
151
- const buffer: BufferedEvent[] = []
152
- let nextId = 1
153
-
154
- // Returns true when this subscriber should receive this record (topic + access + scope).
155
- function deliverable(
156
- s: Subscriber,
157
- table: string,
158
- record: Record<string, unknown>,
159
- id: unknown,
160
- ): boolean {
161
- const entry = tableEntryForName(opts.access, table)
162
- if (!entry) return false
163
- const topicMatch =
164
- s.subscriptions.has(table) ||
165
- (id != null && s.subscriptions.has(`${table}/${String(id)}`))
166
- if (!topicMatch) return false
167
- const ctx = {
168
- user: s.user,
169
- request: new Request('http://realtime.local'),
170
- row: record,
171
- session: { activeOrganizationId: s.activeOrganizationId },
172
- }
173
- if (typeof entry.get === 'function') return false // function get-rules unsupported on realtime v1
174
- if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
175
- return false
176
- if (!scopeOk(entry, ctx, record)) return false
177
- return true
178
- }
179
-
180
- return {
181
- async start() {},
182
- async close() {},
183
- register(send) {
184
- const id = crypto.randomUUID()
185
- subscribers.set(id, {
186
- id,
187
- send,
188
- user: null,
189
- activeOrganizationId: null,
190
- subscriptions: new Set(),
191
- })
192
- return { id }
193
- },
194
- setContext(id, ctx) {
195
- const s = subscribers.get(id)
196
- if (!s) return { gap: false }
197
- s.user = ctx.user
198
- s.activeOrganizationId = ctx.activeOrganizationId
199
- s.subscriptions = ctx.subscriptions
200
-
201
- const since = ctx.since ?? null
202
- if (since == null) return { gap: false } // fresh client; current data already loaded by queries
203
-
204
- const maxId = nextId - 1
205
- // since ahead of anything we issued => server restarted / different epoch => full catch-up.
206
- if (since > maxId) return { gap: true }
207
- const oldest = buffer.length ? buffer[0]!.eventId : nextId
208
- const gap = since < oldest - 1 // events between since and oldest were evicted
209
- for (const e of buffer) {
210
- if (e.eventId <= since) continue
211
- if (!deliverable(s, e.table, e.record, e.record['id'])) continue
212
- s.send(
213
- JSON.stringify({
214
- eventId: e.eventId,
215
- action: e.action,
216
- table: e.table,
217
- record: e.record,
218
- }),
219
- )
220
- }
221
- return { gap }
222
- },
223
- unregister(id) {
224
- subscribers.delete(id)
225
- },
226
- publish(table, action, record) {
227
- const entry = tableEntryForName(opts.access, table)
228
- if (!entry) return
229
- const eventId = nextId++
230
- buffer.push({ eventId, table, action, record })
231
- if (buffer.length > bufferSize) buffer.shift()
232
- const id = record['id']
233
- const payload = JSON.stringify({ eventId, action, table, record })
234
- for (const s of subscribers.values()) {
235
- if (!deliverable(s, table, record, id)) continue
236
- s.send(payload)
237
- }
238
- },
239
- }
240
- }
241
-
242
- export const createMemoryRealtimeBroker = createRealtimeBroker