bunderstack 0.15.2 → 0.17.0-beta.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.
Files changed (47) hide show
  1. package/README.md +25 -138
  2. package/package.json +22 -14
  3. package/src/access.ts +24 -1
  4. package/src/api/api-types.types.ts +106 -0
  5. package/src/api/builder.ts +52 -0
  6. package/src/api/context.ts +83 -0
  7. package/src/api/crud-router.ts +321 -0
  8. package/src/api/openapi.ts +184 -0
  9. package/src/api/realtime-router.ts +75 -0
  10. package/src/api/registry.ts +338 -0
  11. package/src/api/router.ts +34 -0
  12. package/src/api/storage-router.ts +224 -0
  13. package/src/api/types.ts +84 -0
  14. package/src/auth.ts +5 -0
  15. package/src/blueprint.ts +88 -105
  16. package/src/config.ts +73 -77
  17. package/src/cron.ts +2 -1
  18. package/src/crud-operations.ts +488 -0
  19. package/src/dialect.ts +1 -1
  20. package/src/env.ts +28 -21
  21. package/src/errors.ts +90 -23
  22. package/src/handler.ts +16 -44
  23. package/src/index.ts +283 -294
  24. package/src/internal-tables-pg.ts +1 -17
  25. package/src/internal-tables.ts +0 -31
  26. package/src/jobs/define.ts +75 -21
  27. package/src/jobs/index.ts +3 -9
  28. package/src/jobs/queue.ts +14 -6
  29. package/src/jobs/slots.ts +52 -0
  30. package/src/jobs/worker.ts +142 -42
  31. package/src/manifest.ts +84 -93
  32. package/src/realtime/facade.ts +16 -13
  33. package/src/realtime/filter.ts +77 -0
  34. package/src/realtime/heartbeat.ts +80 -0
  35. package/src/realtime/publisher.ts +46 -0
  36. package/src/standard-schema.ts +59 -0
  37. package/src/storage/index.ts +8 -0
  38. package/src/storage/operations.ts +398 -0
  39. package/src/crud.ts +0 -408
  40. package/src/jobs/cron-auth.ts +0 -28
  41. package/src/jobs/cron-router.ts +0 -135
  42. package/src/jobs/cron-runner.ts +0 -224
  43. package/src/jobs/local-cron.ts +0 -78
  44. package/src/realtime/index.ts +0 -250
  45. package/src/realtime/redis.ts +0 -228
  46. package/src/storage/router.ts +0 -531
  47. package/src/trpc.ts +0 -57
@@ -1,32 +1,57 @@
1
1
  // src/jobs/worker.ts — the queue worker. One `tick()` is a full cycle:
2
2
  // recover expired leases → reap old succeeded rows → claim and run queue jobs.
3
- import { and, eq, inArray, is, isNotNull, lt, lte, sql } from 'drizzle-orm'
3
+ import { and, eq, inArray, is, isNotNull, lt, lte, max, sql } from 'drizzle-orm'
4
4
  import { PgDatabase } from 'drizzle-orm/pg-core'
5
5
 
6
6
  import type { AnyDb } from '../dialect'
7
- import type { AnyJobDefinition, JobsDefs, JobsRuntimeFacade } from './define'
7
+ import type {
8
+ AnyBackgroundDefinition,
9
+ JobsDefs,
10
+ JobsRuntimeFacade,
11
+ TickResult,
12
+ } from './define'
8
13
 
9
14
  import { jobsTableFor } from '../internal-tables'
15
+ import { validateStandardSchema } from '../standard-schema'
16
+ import { parseCron } from './cron'
10
17
  import { backoffMs, DEFAULT_RETRIES, DEFAULT_TIMEOUT_MS } from './define'
18
+ import { enqueueJob } from './queue'
19
+ import { CRON_PREFIX, floorSlot, slotsDue, SLOT_MS } from './slots'
11
20
 
12
21
  const CLAIM_BATCH = 10
13
22
  const SUCCEEDED_RETENTION_MS = 24 * 60 * 60 * 1000
23
+ const REAP_INTERVAL_MS = 60 * 60_000
14
24
 
15
25
  type JobRow = {
16
26
  id: string
17
27
  type: string
18
28
  payloadJson: string
19
29
  attempts: number
30
+ runAt: number
20
31
  }
21
32
 
22
33
  function toError(err: unknown): Error {
23
34
  return err instanceof Error ? err : new Error(String(err))
24
35
  }
25
36
 
26
- function maxAttempts(def: AnyJobDefinition): number {
37
+ function maxAttempts(def: AnyBackgroundDefinition): number {
27
38
  return 1 + (def.retries ?? DEFAULT_RETRIES)
28
39
  }
29
40
 
41
+ /** Resolve a stored row type back to its definition. Cron rows carry the
42
+ * reserved prefix; queue rows use the definition key verbatim. */
43
+ function definitionFor(
44
+ defs: JobsDefs,
45
+ type: string,
46
+ ): AnyBackgroundDefinition | undefined {
47
+ if (type.startsWith(CRON_PREFIX)) {
48
+ const def = defs[type.slice(CRON_PREFIX.length)]
49
+ return def?.kind === 'cron' ? def : undefined
50
+ }
51
+ const def = defs[type]
52
+ return def?.kind === 'job' ? def : undefined
53
+ }
54
+
30
55
  /** Terminal queue rows release their dedupe key. */
31
56
  function terminalPatch() {
32
57
  return { dedupeKey: null }
@@ -41,21 +66,68 @@ export function createJobRunner(deps: {
41
66
  const { db, defs } = deps
42
67
  const t = jobsTableFor(db)
43
68
  const ctx = { ...deps.ctx } as Record<string, unknown>
69
+ let lastReapAt = 0
70
+
71
+ /** Cron rows carry no payload — their handler input is the slot itself. */
72
+ function resolveInput(def: AnyBackgroundDefinition, row: JobRow): unknown {
73
+ if (def.kind === 'cron') {
74
+ return { scheduledFor: new Date(Number(row.runAt)) }
75
+ }
76
+ const raw = JSON.parse(row.payloadJson)
77
+ return def.input
78
+ ? validateStandardSchema(def.input, raw, 'job payload')
79
+ : undefined
80
+ }
81
+
82
+ /**
83
+ * The watermark is the newest slot we already stored for this cron. When no
84
+ * rows exist — a newly declared cron, or one whose rows were reaped — anchor
85
+ * one slot before now so the current minute is eligible and nothing older is.
86
+ */
87
+ async function cronWatermark(type: string, now: number): Promise<number> {
88
+ const rows = await db
89
+ .select({ latest: max(t.runAt) })
90
+ .from(t)
91
+ .where(eq(t.type, type))
92
+ const latest = rows[0]?.latest
93
+ return latest == null ? floorSlot(now) - SLOT_MS : Number(latest)
94
+ }
95
+
96
+ /** Enqueue a row per due slot. The unique(type, dedupeKey) constraint makes
97
+ * this safe to run concurrently in any number of processes. */
98
+ async function materializeCronSlots(now: number) {
99
+ for (const [name, def] of Object.entries(defs)) {
100
+ if (def.kind !== 'cron') continue
101
+ const type = `${CRON_PREFIX}${name}`
102
+ const from = await cronWatermark(type, now)
103
+ const slots = slotsDue({
104
+ cron: parseCron(def.schedule),
105
+ from,
106
+ to: now,
107
+ catchUp: def.catchUp,
108
+ catchUpWindowMs: def.catchUpWindow,
109
+ })
110
+ for (const slot of slots) {
111
+ await enqueueJob(db, defs, name, null, {
112
+ runAt: slot,
113
+ dedupeKey: String(slot),
114
+ })
115
+ }
116
+ }
117
+ }
118
+
44
119
  async function fireOnFailed(
45
- def: AnyJobDefinition,
46
- payloadJson: string,
120
+ def: AnyBackgroundDefinition,
121
+ input: unknown,
47
122
  error: Error,
48
123
  ) {
49
124
  if (!def.onFailed) return
50
- let input: unknown
51
125
  try {
52
- const raw = JSON.parse(payloadJson)
53
- input = def.input ? def.input.parse(raw) : undefined
54
- } catch {
55
- input = undefined // payload unusable; the hook still gets the error
56
- }
57
- try {
58
- await def.onFailed(input, error, ctx as never)
126
+ await (def.onFailed as (i: unknown, e: Error, c: unknown) => unknown)(
127
+ input,
128
+ error,
129
+ ctx,
130
+ )
59
131
  } catch (hookErr) {
60
132
  console.error('[bunderstack] onFailed hook threw:', hookErr)
61
133
  }
@@ -70,6 +142,7 @@ export function createJobRunner(deps: {
70
142
  payloadJson: t.payloadJson,
71
143
  attempts: t.attempts,
72
144
  lastError: t.lastError,
145
+ runAt: t.runAt,
73
146
  })
74
147
  .from(t)
75
148
  .where(
@@ -80,9 +153,9 @@ export function createJobRunner(deps: {
80
153
  ),
81
154
  )
82
155
  for (const row of expired) {
83
- const def = defs[row.type]
156
+ const def = definitionFor(defs, row.type)
84
157
  const error = new Error('lease expired (worker crashed or timed out)')
85
- if (!def || def.kind !== 'job') {
158
+ if (!def) {
86
159
  await db
87
160
  .update(t)
88
161
  .set({
@@ -106,7 +179,7 @@ export function createJobRunner(deps: {
106
179
  ...terminalPatch(),
107
180
  })
108
181
  .where(eq(t.id, row.id))
109
- await fireOnFailed(def, row.payloadJson, error)
182
+ await fireOnFailed(def, resolveInput(def, row), error)
110
183
  } else {
111
184
  await db
112
185
  .update(t)
@@ -167,6 +240,7 @@ export function createJobRunner(deps: {
167
240
  type: t.type,
168
241
  payloadJson: t.payloadJson,
169
242
  attempts: t.attempts,
243
+ runAt: t.runAt,
170
244
  })
171
245
  return rows
172
246
  }
@@ -174,15 +248,19 @@ export function createJobRunner(deps: {
174
248
  // `now` is the tick's injected clock: retry runAt math uses it so tests can
175
249
  // drive backoff deterministically. finishedAt uses the real clock (a handler
176
250
  // may run long past the tick's start).
177
- async function runJob(row: JobRow, def: AnyJobDefinition, now: number) {
251
+ async function runJob(
252
+ row: JobRow,
253
+ def: AnyBackgroundDefinition,
254
+ now: number,
255
+ leaseUntil: number,
256
+ ): Promise<'ran' | 'failed' | 'lost'> {
178
257
  let input: unknown
179
258
  try {
180
- const raw = JSON.parse(row.payloadJson)
181
- input = def.input ? def.input.parse(raw) : undefined
259
+ input = resolveInput(def, row)
182
260
  } catch (err) {
183
261
  // Stored payload no longer parses (schema drift): retrying can't help.
184
262
  const e = toError(err)
185
- await db
263
+ const updated = await db
186
264
  .update(t)
187
265
  .set({
188
266
  status: 'failed',
@@ -191,13 +269,15 @@ export function createJobRunner(deps: {
191
269
  lastError: e.message,
192
270
  ...terminalPatch(),
193
271
  })
194
- .where(eq(t.id, row.id))
195
- await fireOnFailed(def, row.payloadJson, e)
196
- return
272
+ .where(and(eq(t.id, row.id), eq(t.lockedUntil, leaseUntil)))
273
+ .returning({ id: t.id })
274
+ if (!updated[0]) return 'lost'
275
+ await fireOnFailed(def, undefined, e)
276
+ return 'failed'
197
277
  }
198
278
  try {
199
- await def.handler(input, ctx as never)
200
- await db
279
+ await (def.handler as (i: unknown, c: unknown) => unknown)(input, ctx)
280
+ const updated = await db
201
281
  .update(t)
202
282
  .set({
203
283
  status: 'succeeded',
@@ -205,11 +285,14 @@ export function createJobRunner(deps: {
205
285
  lockedUntil: null,
206
286
  ...terminalPatch(),
207
287
  })
208
- .where(eq(t.id, row.id))
288
+ .where(and(eq(t.id, row.id), eq(t.lockedUntil, leaseUntil)))
289
+ .returning({ id: t.id })
290
+ if (!updated[0]) return 'lost'
291
+ return 'ran'
209
292
  } catch (err) {
210
293
  const e = toError(err)
211
294
  if (Number(row.attempts) < maxAttempts(def)) {
212
- await db
295
+ const updated = await db
213
296
  .update(t)
214
297
  .set({
215
298
  status: 'pending',
@@ -217,9 +300,11 @@ export function createJobRunner(deps: {
217
300
  runAt: now + backoffMs(def, Number(row.attempts)),
218
301
  lastError: e.message,
219
302
  })
220
- .where(eq(t.id, row.id))
303
+ .where(and(eq(t.id, row.id), eq(t.lockedUntil, leaseUntil)))
304
+ .returning({ id: t.id })
305
+ if (!updated[0]) return 'lost'
221
306
  } else {
222
- await db
307
+ const updated = await db
223
308
  .update(t)
224
309
  .set({
225
310
  status: 'failed',
@@ -228,19 +313,22 @@ export function createJobRunner(deps: {
228
313
  lastError: e.message,
229
314
  ...terminalPatch(),
230
315
  })
231
- .where(eq(t.id, row.id))
232
- await fireOnFailed(def, row.payloadJson, e)
316
+ .where(and(eq(t.id, row.id), eq(t.lockedUntil, leaseUntil)))
317
+ .returning({ id: t.id })
318
+ if (!updated[0]) return 'lost'
319
+ await fireOnFailed(def, input, e)
233
320
  }
321
+ return 'failed'
234
322
  }
235
323
  }
236
324
 
237
- async function runClaimable(now: number) {
238
- const work: Promise<void>[] = []
239
- for (const [type, candidate] of Object.entries(defs)) {
240
- if (candidate.kind !== 'job') continue
241
- const def = candidate
325
+ async function runClaimable(now: number): Promise<TickResult> {
326
+ const work: Promise<'ran' | 'failed' | 'lost'>[] = []
327
+ let totalClaimed = 0
328
+ for (const [name, def] of Object.entries(defs)) {
329
+ const type = def.kind === 'cron' ? `${CRON_PREFIX}${name}` : name
242
330
  let limit = CLAIM_BATCH
243
- if (def.concurrency !== undefined) {
331
+ if (def.kind === 'job' && def.concurrency !== undefined) {
244
332
  const runningRows = await db
245
333
  .select({ id: t.id })
246
334
  .from(t)
@@ -251,16 +339,28 @@ export function createJobRunner(deps: {
251
339
  }
252
340
  const leaseUntil = now + (def.timeout ?? DEFAULT_TIMEOUT_MS)
253
341
  const claimed = await claim(type, limit, now, leaseUntil)
254
- for (const row of claimed) work.push(runJob(row, def, now))
342
+ totalClaimed += claimed.length
343
+ for (const row of claimed) work.push(runJob(row, def, now, leaseUntil))
344
+ }
345
+ const outcomes = await Promise.all(work)
346
+ let ran = 0
347
+ let failed = 0
348
+ for (const outcome of outcomes) {
349
+ if (outcome === 'ran') ran++
350
+ else if (outcome === 'failed') failed++
255
351
  }
256
- await Promise.all(work)
352
+ return { claimed: totalClaimed, ran, failed }
257
353
  }
258
354
 
259
355
  return {
260
- async tick(now: number = Date.now()) {
356
+ async tick(now: number = Date.now()): Promise<TickResult> {
357
+ await materializeCronSlots(now)
261
358
  await recoverExpiredLeases(now)
262
- await reapSucceeded(now)
263
- await runClaimable(now)
359
+ if (now - lastReapAt >= REAP_INTERVAL_MS) {
360
+ lastReapAt = now
361
+ await reapSucceeded(now)
362
+ }
363
+ return runClaimable(now)
264
364
  },
265
365
  setJobsFacade(f: JobsRuntimeFacade) {
266
366
  ctx.jobs = f
package/src/manifest.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
2
+
1
3
  import { getTableName, isTable } from 'drizzle-orm'
2
- import { z, type ZodType } from 'zod'
4
+ import * as v from 'valibot'
3
5
 
4
6
  import type { Dialect } from './dialect'
5
7
  import type { EnvConfigInput } from './env'
@@ -7,12 +9,15 @@ import type { JobsDefs } from './jobs/define'
7
9
  import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
8
10
 
9
11
  import {
10
- bunderstackCronRuns,
11
12
  bunderstackFiles,
12
13
  bunderstackIdempotency,
13
14
  bunderstackJobs,
14
15
  } from './internal-tables'
15
16
  import { parseCron } from './jobs/cron'
17
+ import {
18
+ StandardSchemaValidationError,
19
+ validateStandardSchema,
20
+ } from './standard-schema'
16
21
 
17
22
  export type ManifestEnvVar = {
18
23
  key: string
@@ -44,95 +49,77 @@ export type BunderstackManifest = {
44
49
  }
45
50
  }
46
51
 
47
- const nonEmpty = z.string().min(1)
48
- const migrationDirectory = nonEmpty.refine(
49
- (value) =>
50
- value.startsWith('/') ||
51
- (!value.includes('\\') &&
52
- value.split('/').every((part) => part !== '..' && part !== '')),
53
- {
54
- message:
55
- 'migrationsDirectory must be an absolute path or a relative path without traversal',
56
- },
52
+ const nonEmpty = v.pipe(v.string(), v.minLength(1))
53
+ const migrationDirectory = v.pipe(
54
+ nonEmpty,
55
+ v.check(
56
+ (value) =>
57
+ value.startsWith('/') ||
58
+ (!value.includes('\\') &&
59
+ value.split('/').every((part) => part !== '..' && part !== '')),
60
+ 'migrationsDirectory must be an absolute path or a relative path without traversal',
61
+ ),
57
62
  )
58
- const cronSchedule = nonEmpty.refine(
59
- (value) => {
63
+ const cronSchedule = v.pipe(
64
+ nonEmpty,
65
+ v.check((value) => {
60
66
  try {
61
67
  parseCron(value)
62
68
  return true
63
69
  } catch {
64
70
  return false
65
71
  }
66
- },
67
- { message: 'invalid cron schedule' },
72
+ }, 'invalid cron schedule'),
68
73
  )
69
74
 
70
- const manifestSchema = z
71
- .object({
72
- version: z.literal(3),
73
- database: z
74
- .object({
75
- dialect: z.enum(['sqlite', 'pg']),
76
- migrationsDirectory: migrationDirectory,
77
- tables: z.array(
78
- z
79
- .object({
80
- exportName: nonEmpty,
81
- physicalName: nonEmpty,
82
- system: z.boolean(),
83
- })
84
- .strict(),
85
- ),
86
- })
87
- .strict(),
88
- storage: z
89
- .object({
90
- defaultBucket: nonEmpty,
91
- buckets: z.array(
92
- z
93
- .object({
94
- name: nonEmpty,
95
- visibility: z.enum(['public', 'private']),
96
- })
97
- .strict(),
98
- ),
99
- })
100
- .strict(),
101
- realtime: z.object({ required: z.boolean() }).strict(),
102
- environment: z.array(
103
- z
104
- .object({
105
- key: nonEmpty,
106
- required: z.boolean(),
107
- scope: z.enum(['server', 'client']),
108
- })
109
- .strict(),
75
+ const manifestSchema = v.strictObject({
76
+ version: v.literal(3),
77
+ database: v.strictObject({
78
+ dialect: v.picklist(['sqlite', 'pg']),
79
+ migrationsDirectory: migrationDirectory,
80
+ tables: v.array(
81
+ v.strictObject({
82
+ exportName: nonEmpty,
83
+ physicalName: nonEmpty,
84
+ system: v.boolean(),
85
+ }),
110
86
  ),
111
- background: z
112
- .object({
113
- jobs: z.array(z.object({ name: nonEmpty }).strict()),
114
- cron: z.array(
115
- z
116
- .object({
117
- name: nonEmpty,
118
- schedule: cronSchedule,
119
- timezone: z.literal('UTC'),
120
- })
121
- .strict(),
122
- ),
123
- maintenance: z.array(
124
- z
125
- .object({
126
- name: z.literal('storage-sweep'),
127
- schedule: cronSchedule,
128
- timezone: z.literal('UTC'),
129
- })
130
- .strict(),
131
- ),
132
- })
133
- .strict(),
134
- })
135
- .strict()
87
+ }),
88
+ storage: v.strictObject({
89
+ defaultBucket: nonEmpty,
90
+ buckets: v.array(
91
+ v.strictObject({
92
+ name: nonEmpty,
93
+ visibility: v.picklist(['public', 'private']),
94
+ }),
95
+ ),
96
+ }),
97
+ realtime: v.strictObject({ required: v.boolean() }),
98
+ environment: v.array(
99
+ v.strictObject({
100
+ key: nonEmpty,
101
+ required: v.boolean(),
102
+ scope: v.picklist(['server', 'client']),
103
+ }),
104
+ ),
105
+ background: v.strictObject({
106
+ jobs: v.array(v.strictObject({ name: nonEmpty })),
107
+ cron: v.array(
108
+ v.strictObject({
109
+ name: nonEmpty,
110
+ schedule: cronSchedule,
111
+ timezone: v.literal('UTC'),
112
+ }),
113
+ ),
114
+ maintenance: v.array(
115
+ v.strictObject({
116
+ name: v.literal('storage-sweep'),
117
+ schedule: cronSchedule,
118
+ timezone: v.literal('UTC'),
119
+ }),
120
+ ),
121
+ }),
122
+ })
136
123
 
137
124
  function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
138
125
  return [...entries].sort((left, right) => key(left).localeCompare(key(right)))
@@ -162,14 +149,19 @@ function describeTables(schema: Record<string, unknown>) {
162
149
  }
163
150
 
164
151
  function describeSection(
165
- section: Record<string, ZodType> | undefined,
152
+ section: Record<string, StandardSchemaV1> | undefined,
166
153
  scope: ManifestEnvVar['scope'],
167
154
  ): ManifestEnvVar[] {
168
- return Object.entries(section ?? {}).map(([key, schema]) => ({
169
- key,
170
- required: !schema.safeParse(undefined).success,
171
- scope,
172
- }))
155
+ return Object.entries(section ?? {}).map(([key, schema]) => {
156
+ let required = false
157
+ try {
158
+ validateStandardSchema(schema, undefined, 'env')
159
+ } catch (error) {
160
+ if (!(error instanceof StandardSchemaValidationError)) throw error
161
+ required = true
162
+ }
163
+ return { key, required, scope }
164
+ })
173
165
  }
174
166
 
175
167
  function systemTables() {
@@ -189,16 +181,15 @@ function systemTables() {
189
181
  physicalName: getTableName(bunderstackJobs),
190
182
  system: true,
191
183
  },
192
- {
193
- exportName: '_system.scheduledRuns',
194
- physicalName: getTableName(bunderstackCronRuns),
195
- system: true,
196
- },
197
184
  ]
198
185
  }
199
186
 
200
187
  export function parseManifest(value: unknown): BunderstackManifest {
201
- const manifest = manifestSchema.parse(value) as BunderstackManifest
188
+ const manifest = validateStandardSchema(
189
+ manifestSchema,
190
+ value,
191
+ 'manifest',
192
+ ) as BunderstackManifest
202
193
  rejectDuplicates(
203
194
  'database physical table',
204
195
  manifest.database.tables.map((entry) => entry.physicalName),
@@ -1,6 +1,9 @@
1
1
  import { getTableName, type InferSelectModel, type Table } from 'drizzle-orm'
2
2
 
3
- import type { RealtimeAction, RealtimeBroker } from './index'
3
+ import type {
4
+ RealtimeAction,
5
+ RealtimePublisher,
6
+ } from './publisher'
4
7
 
5
8
  export type RealtimeTransport = 'disabled' | 'memory' | 'redis'
6
9
 
@@ -23,30 +26,30 @@ export interface RealtimeFacade<
23
26
  }
24
27
 
25
28
  export function createRealtimeFacade<TSchema extends Record<string, unknown>>(
26
- broker?: RealtimeBroker,
27
- transport: RealtimeTransport = broker ? 'memory' : 'disabled',
29
+ publisher?: RealtimePublisher,
30
+ transport: RealtimeTransport = publisher ? 'memory' : 'disabled',
28
31
  ): RealtimeFacade<TSchema> {
29
- if (!broker && transport !== 'disabled') {
32
+ if (!publisher && transport !== 'disabled') {
30
33
  throw new Error(
31
- '[bunderstack] an enabled realtime transport requires a broker',
34
+ '[bunderstack] an enabled realtime transport requires a publisher',
32
35
  )
33
36
  }
34
- if (broker && transport === 'disabled') {
37
+ if (publisher && transport === 'disabled') {
35
38
  throw new Error(
36
- '[bunderstack] a realtime broker cannot use the disabled transport',
39
+ '[bunderstack] a realtime publisher cannot use the disabled transport',
37
40
  )
38
41
  }
39
42
 
40
43
  return {
41
- enabled: broker !== undefined,
44
+ enabled: publisher !== undefined,
42
45
  transport,
43
46
  async publish(table, action, record) {
44
- if (!broker) return
45
- await broker.publish(
46
- getTableName(table),
47
+ if (!publisher) return
48
+ await publisher.publish('change', {
49
+ table: getTableName(table),
47
50
  action,
48
- record as unknown as Record<string, unknown>,
49
- )
51
+ record: record as unknown as Record<string, unknown>,
52
+ })
50
53
  },
51
54
  }
52
55
  }
@@ -0,0 +1,77 @@
1
+ import { getEventMeta, withEventMeta } from '@orpc/server'
2
+
3
+ import type {
4
+ AccessUser,
5
+ ResolvedAccess,
6
+ } from '../access'
7
+ import type { RealtimeChange } from './publisher'
8
+
9
+ import {
10
+ checkAccess,
11
+ rowMatchesScope,
12
+ tableEntryForName,
13
+ } from '../access'
14
+
15
+ export interface FilterRealtimeChangesOptions {
16
+ subscriptions: readonly string[]
17
+ access: ResolvedAccess
18
+ request: Request
19
+ getSession: () => Promise<{
20
+ user: AccessUser | null
21
+ activeOrganizationId: string | null
22
+ }>
23
+ }
24
+
25
+ export async function* filterRealtimeChanges(
26
+ source: AsyncIterable<RealtimeChange>,
27
+ options: FilterRealtimeChangesOptions,
28
+ ): AsyncGenerator<RealtimeChange, void, void> {
29
+ const subscriptions = new Set(options.subscriptions)
30
+ let sessionPromise:
31
+ | ReturnType<FilterRealtimeChangesOptions['getSession']>
32
+ | undefined
33
+ const getSession = () => (sessionPromise ??= options.getSession())
34
+
35
+ for await (const change of source) {
36
+ const entry = tableEntryForName(options.access, change.table)
37
+ if (!entry?.enabled) continue
38
+
39
+ const recordId = change.record.id
40
+ if (
41
+ !subscriptions.has(change.table) &&
42
+ (recordId == null ||
43
+ !subscriptions.has(`${change.table}/${String(recordId)}`))
44
+ ) {
45
+ continue
46
+ }
47
+ if (entry.get === 'deny') continue
48
+
49
+ const needsSession = entry.get !== 'public' || entry.readScope !== undefined
50
+ const session = needsSession
51
+ ? await getSession()
52
+ : { user: null, activeOrganizationId: null }
53
+ const context = {
54
+ request: options.request,
55
+ user: session.user,
56
+ row: change.record,
57
+ session: { activeOrganizationId: session.activeOrganizationId },
58
+ }
59
+ if (!(await checkAccess(entry.get, context, entry.ownerColumn)).allowed) {
60
+ continue
61
+ }
62
+ if (
63
+ entry.readScope &&
64
+ !rowMatchesScope(change.record, entry.readScope(context))
65
+ ) {
66
+ continue
67
+ }
68
+
69
+ const projected: RealtimeChange = {
70
+ table: change.table,
71
+ action: change.action,
72
+ record: change.record,
73
+ }
74
+ const meta = getEventMeta(change)
75
+ yield meta ? withEventMeta(projected, meta) : projected
76
+ }
77
+ }