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/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
|
-
}
|
package/src/realtime/index.ts
DELETED
|
@@ -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
|