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,224 +0,0 @@
1
- import { and, eq, lt, or, sql } from 'drizzle-orm'
2
-
3
- import type { AnyDb } from '../dialect'
4
- import type { BackgroundDefs } from './define'
5
-
6
- import { cronRunsTableFor } from '../internal-tables'
7
- import { cronMatches, parseCron } from './cron'
8
-
9
- const LEASE_MS = 60_000
10
-
11
- export type CronRunResult =
12
- | { status: 'succeeded' }
13
- | { status: 'duplicate' }
14
- | { status: 'running' }
15
-
16
- export async function runScheduledSlot(args: {
17
- db: AnyDb
18
- taskId: string
19
- schedule: string
20
- slot: number
21
- now: number
22
- run: (scheduledFor: Date) => Promise<void> | void
23
- leaseMs?: number
24
- heartbeatIntervalMs?: number
25
- heartbeatCleanupTimeoutMs?: number
26
- }): Promise<CronRunResult> {
27
- const { db, taskId, schedule, slot, now, run } = args
28
- const leaseMs = args.leaseMs ?? LEASE_MS
29
- const heartbeatIntervalMs =
30
- args.heartbeatIntervalMs ?? Math.max(1, Math.floor(leaseMs / 4))
31
- const heartbeatCleanupTimeoutMs = args.heartbeatCleanupTimeoutMs ?? 1_000
32
-
33
- if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
34
- throw new Error('[bunderstack] cron slot does not match its schedule')
35
- }
36
-
37
- const t = cronRunsTableFor(db)
38
- const leaseUntil = now + leaseMs
39
- const inserted = await db
40
- .insert(t)
41
- .values({
42
- taskId,
43
- scheduledAt: slot,
44
- status: 'running',
45
- attempts: 1,
46
- lockedUntil: leaseUntil,
47
- startedAt: now,
48
- })
49
- .onConflictDoNothing({ target: [t.taskId, t.scheduledAt] })
50
- .returning({ taskId: t.taskId, attempts: t.attempts })
51
-
52
- let ownershipAttempt: number
53
- if (inserted[0]) {
54
- ownershipAttempt = Number(inserted[0].attempts)
55
- } else {
56
- const existing = await db
57
- .select({ status: t.status, lockedUntil: t.lockedUntil })
58
- .from(t)
59
- .where(and(eq(t.taskId, taskId), eq(t.scheduledAt, slot)))
60
- .limit(1)
61
- const row = existing[0]
62
- if (!row || row.status === 'succeeded') return { status: 'duplicate' }
63
- if (row.status === 'running' && Number(row.lockedUntil) >= now) {
64
- return { status: 'running' }
65
- }
66
- const reclaimed = await db
67
- .update(t)
68
- .set({
69
- status: 'running',
70
- lockedUntil: leaseUntil,
71
- startedAt: now,
72
- attempts: sql`${t.attempts} + 1`,
73
- lastError: null,
74
- })
75
- .where(
76
- and(
77
- eq(t.taskId, taskId),
78
- eq(t.scheduledAt, slot),
79
- or(eq(t.status, 'failed'), lt(t.lockedUntil, now)),
80
- ),
81
- )
82
- .returning({ taskId: t.taskId, attempts: t.attempts })
83
- const reclaimedRow = reclaimed[0]
84
- if (!reclaimedRow) return { status: 'running' }
85
- ownershipAttempt = Number(reclaimedRow.attempts)
86
- }
87
-
88
- let heartbeatTimer: Timer | undefined
89
- let heartbeatInFlight: Promise<void> | undefined
90
- let heartbeatStopped = false
91
-
92
- const scheduleHeartbeat = () => {
93
- heartbeatTimer = setTimeout(() => {
94
- heartbeatTimer = undefined
95
- if (heartbeatStopped) return
96
-
97
- heartbeatInFlight = (async () => {
98
- try {
99
- const renewUntil = Date.now() + leaseMs
100
- await db
101
- .update(t)
102
- .set({ lockedUntil: renewUntil })
103
- .where(
104
- and(
105
- eq(t.taskId, taskId),
106
- eq(t.scheduledAt, slot),
107
- eq(t.status, 'running'),
108
- eq(t.startedAt, now),
109
- eq(t.attempts, ownershipAttempt),
110
- ),
111
- )
112
- } catch {
113
- // Best effort renewal
114
- }
115
- })()
116
-
117
- void heartbeatInFlight.finally(() => {
118
- heartbeatInFlight = undefined
119
- if (!heartbeatStopped) scheduleHeartbeat()
120
- })
121
- }, heartbeatIntervalMs)
122
- }
123
-
124
- const stopHeartbeat = async () => {
125
- heartbeatStopped = true
126
- if (heartbeatTimer) {
127
- clearTimeout(heartbeatTimer)
128
- heartbeatTimer = undefined
129
- }
130
- const inFlight = heartbeatInFlight
131
- if (!inFlight) return
132
-
133
- let cleanupTimer: Timer | undefined
134
- try {
135
- await Promise.race([
136
- inFlight,
137
- new Promise<void>((resolve) => {
138
- cleanupTimer = setTimeout(resolve, heartbeatCleanupTimeoutMs)
139
- }),
140
- ])
141
- } finally {
142
- if (cleanupTimer) clearTimeout(cleanupTimer)
143
- }
144
- }
145
-
146
- scheduleHeartbeat()
147
-
148
- try {
149
- await run(new Date(slot))
150
-
151
- const updated = await db
152
- .update(t)
153
- .set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
154
- .where(
155
- and(
156
- eq(t.taskId, taskId),
157
- eq(t.scheduledAt, slot),
158
- eq(t.status, 'running'),
159
- eq(t.startedAt, now),
160
- eq(t.attempts, ownershipAttempt),
161
- ),
162
- )
163
- .returning({ taskId: t.taskId })
164
-
165
- if (!updated[0]) {
166
- throw new Error(
167
- '[bunderstack] cron lease ownership was lost during execution',
168
- )
169
- }
170
-
171
- return { status: 'succeeded' }
172
- } catch (error) {
173
- const message = error instanceof Error ? error.message : String(error)
174
- await db
175
- .update(t)
176
- .set({
177
- status: 'failed',
178
- lockedUntil: null,
179
- lastError: message,
180
- finishedAt: Date.now(),
181
- })
182
- .where(
183
- and(
184
- eq(t.taskId, taskId),
185
- eq(t.scheduledAt, slot),
186
- eq(t.status, 'running'),
187
- eq(t.startedAt, now),
188
- eq(t.attempts, ownershipAttempt),
189
- ),
190
- )
191
- throw error
192
- } finally {
193
- await stopHeartbeat()
194
- }
195
- }
196
-
197
- export async function runCronSlot(args: {
198
- db: AnyDb
199
- defs: BackgroundDefs
200
- ctx: Record<string, unknown>
201
- name: string
202
- slot: number
203
- now: number
204
- leaseMs?: number
205
- heartbeatIntervalMs?: number
206
- heartbeatCleanupTimeoutMs?: number
207
- }): Promise<CronRunResult> {
208
- const definition = args.defs[args.name]
209
- if (!definition || definition.kind !== 'cron') {
210
- throw new Error(`[bunderstack] unknown cron "${args.name}"`)
211
- }
212
- return runScheduledSlot({
213
- db: args.db,
214
- taskId: `cron:${args.name}`,
215
- schedule: definition.schedule,
216
- slot: args.slot,
217
- now: args.now,
218
- leaseMs: args.leaseMs,
219
- heartbeatIntervalMs: args.heartbeatIntervalMs,
220
- heartbeatCleanupTimeoutMs: args.heartbeatCleanupTimeoutMs,
221
- run: (scheduledFor) =>
222
- definition.handler({ scheduledFor }, args.ctx as never),
223
- })
224
- }
@@ -1,78 +0,0 @@
1
- import { cronMatches, parseCron } from './cron'
2
-
3
- type Timer = ReturnType<typeof setTimeout> | number
4
-
5
- export type LocalCronDefinition = {
6
- name: string
7
- schedule: string
8
- }
9
-
10
- export type LocalCronSchedulerOptions = {
11
- cron: readonly LocalCronDefinition[]
12
- runSlot: (name: string, scheduledFor: number) => Promise<void>
13
- now?: () => number
14
- setTimer?: (callback: () => void, delayMs: number) => Timer
15
- clearTimer?: (timer: Timer) => void
16
- onError?: (error: Error) => void
17
- }
18
-
19
- export type LocalCronScheduler = {
20
- tick: () => Promise<void>
21
- close: () => Promise<void>
22
- }
23
-
24
- /**
25
- * Runs declared cron handlers locally. Production schedules should be delivered
26
- * by the hosting platform through the signed cron endpoint instead.
27
- */
28
- export function startLocalCronScheduler(
29
- options: LocalCronSchedulerOptions,
30
- ): LocalCronScheduler {
31
- const cron = options.cron.map((definition) => ({
32
- ...definition,
33
- expression: parseCron(definition.schedule),
34
- }))
35
- const now = options.now ?? Date.now
36
- const setTimer = options.setTimer ?? setTimeout
37
- const clearTimer = options.clearTimer ?? clearTimeout
38
- let timer: Timer | undefined
39
- let closed = false
40
-
41
- const scheduleNextTick = () => {
42
- if (closed || timer) return
43
-
44
- const delay = 60_000 - (now() % 60_000)
45
- timer = setTimer(() => {
46
- timer = undefined
47
- void tick().catch((error: unknown) => {
48
- options.onError?.(
49
- error instanceof Error ? error : new Error(String(error)),
50
- )
51
- })
52
- }, delay)
53
- }
54
-
55
- const tick = async () => {
56
- if (closed) return
57
-
58
- const scheduledFor = Math.floor(now() / 60_000) * 60_000
59
- for (const definition of cron) {
60
- if (cronMatches(definition.expression, scheduledFor)) {
61
- await options.runSlot(definition.name, scheduledFor)
62
- }
63
- }
64
-
65
- scheduleNextTick()
66
- }
67
-
68
- return {
69
- tick,
70
- async close() {
71
- closed = true
72
- if (timer) {
73
- clearTimer(timer)
74
- timer = undefined
75
- }
76
- },
77
- }
78
- }
@@ -1,250 +0,0 @@
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
- start(): Promise<void>
39
- close(): Promise<void>
40
- register(send: (data: string) => void): { id: string }
41
- setContext(
42
- id: string,
43
- ctx: {
44
- user: AccessUser | null
45
- activeOrganizationId: string | null
46
- subscriptions: Set<string>
47
- since?: number | null
48
- },
49
- ): { gap: boolean } | Promise<{ gap: boolean }>
50
- unregister(id: string): void
51
- publish(
52
- table: string,
53
- action: RealtimeAction,
54
- record: Record<string, unknown>,
55
- ): void | Promise<void>
56
- }
57
-
58
- function tableEntry(
59
- access: ResolvedAccess,
60
- tableName: string,
61
- ): ResolvedTableAccess | undefined {
62
- for (const entry of access.values()) {
63
- if (entry.tableName === tableName) return entry
64
- }
65
- return undefined
66
- }
67
-
68
- function isRecord(value: unknown): value is Record<string, unknown> {
69
- return value !== null && typeof value === 'object' && !Array.isArray(value)
70
- }
71
-
72
- function isRealtimeContextBody(value: unknown): value is RealtimeContextBody {
73
- if (!isRecord(value)) return false
74
- return (
75
- typeof value.clientId === 'string' &&
76
- Array.isArray(value.subscriptions) &&
77
- value.subscriptions.every((item) => typeof item === 'string') &&
78
- (value.since === undefined ||
79
- value.since === null ||
80
- typeof value.since === 'number')
81
- )
82
- }
83
-
84
- function scopeOk(
85
- entry: ResolvedTableAccess,
86
- ctx: Parameters<typeof checkAccessSync>[1],
87
- record: Record<string, unknown>,
88
- ): boolean {
89
- if (!entry.readScope) return true
90
- return rowMatchesScope(record, entry.readScope(ctx))
91
- }
92
-
93
- export function buildRealtimeRouter(
94
- broker: RealtimeBroker,
95
- opts: { auth?: AuthSessionResolver; keepaliveMs?: number },
96
- ): Hono {
97
- const router = new Hono()
98
- const keepaliveMs = opts.keepaliveMs ?? 30000
99
-
100
- router.get('/realtime', () => {
101
- const encoder = new TextEncoder()
102
- let handle: { id: string }
103
- let keepalive: ReturnType<typeof setInterval>
104
-
105
- const stream = new ReadableStream({
106
- async start(controller) {
107
- await broker.start()
108
- const send = (data: string) =>
109
- controller.enqueue(encoder.encode(`data: ${data}\n\n`))
110
- handle = broker.register(send)
111
- send(JSON.stringify({ clientId: handle.id }))
112
- keepalive = setInterval(
113
- () => controller.enqueue(encoder.encode(': ping\n\n')),
114
- keepaliveMs,
115
- )
116
- },
117
- cancel() {
118
- clearInterval(keepalive)
119
- broker.unregister(handle.id)
120
- },
121
- })
122
-
123
- return new Response(stream, {
124
- headers: {
125
- 'Content-Type': 'text/event-stream',
126
- 'Cache-Control': 'no-cache',
127
- Connection: 'keep-alive',
128
- },
129
- })
130
- })
131
-
132
- router.post('/realtime', async (c) => {
133
- const body = await c.req.json().catch(() => null)
134
- if (!isRealtimeContextBody(body)) {
135
- return c.json({ error: 'clientId and subscriptions required' }, 400)
136
- }
137
- const { user, activeOrganizationId } = await resolveSession(
138
- opts.auth,
139
- c.req.raw.headers,
140
- )
141
- const { gap } = await broker.setContext(body.clientId, {
142
- user,
143
- activeOrganizationId,
144
- subscriptions: new Set(body.subscriptions),
145
- since: body.since ?? null,
146
- })
147
- return c.json({ gap }, 200)
148
- })
149
-
150
- return router
151
- }
152
-
153
- export function createRealtimeBroker(opts: {
154
- access: ResolvedAccess
155
- bufferSize?: number
156
- }): RealtimeBroker {
157
- const subscribers = new Map<string, Subscriber>()
158
- const bufferSize = opts.bufferSize ?? 1000
159
- const buffer: BufferedEvent[] = []
160
- let nextId = 1
161
-
162
- // Returns true when this subscriber should receive this record (topic + access + scope).
163
- function deliverable(
164
- s: Subscriber,
165
- table: string,
166
- record: Record<string, unknown>,
167
- id: unknown,
168
- ): boolean {
169
- const entry = tableEntry(opts.access, table)
170
- if (!entry) return false
171
- const topicMatch =
172
- s.subscriptions.has(table) ||
173
- (id != null && s.subscriptions.has(`${table}/${String(id)}`))
174
- if (!topicMatch) return false
175
- const ctx = {
176
- user: s.user,
177
- request: new Request('http://realtime.local'),
178
- row: record,
179
- session: { activeOrganizationId: s.activeOrganizationId },
180
- }
181
- if (typeof entry.get === 'function') return false // function get-rules unsupported on realtime v1
182
- if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
183
- return false
184
- if (!scopeOk(entry, ctx, record)) return false
185
- return true
186
- }
187
-
188
- return {
189
- async start() {},
190
- async close() {},
191
- register(send) {
192
- const id = crypto.randomUUID()
193
- subscribers.set(id, {
194
- id,
195
- send,
196
- user: null,
197
- activeOrganizationId: null,
198
- subscriptions: new Set(),
199
- })
200
- return { id }
201
- },
202
- setContext(id, ctx) {
203
- const s = subscribers.get(id)
204
- if (!s) return { gap: false }
205
- s.user = ctx.user
206
- s.activeOrganizationId = ctx.activeOrganizationId
207
- s.subscriptions = ctx.subscriptions
208
-
209
- const since = ctx.since ?? null
210
- if (since == null) return { gap: false } // fresh client; current data already loaded by queries
211
-
212
- const maxId = nextId - 1
213
- // since ahead of anything we issued => server restarted / different epoch => full catch-up.
214
- if (since > maxId) return { gap: true }
215
- const oldest = buffer.length ? buffer[0]!.eventId : nextId
216
- const gap = since < oldest - 1 // events between since and oldest were evicted
217
- for (const e of buffer) {
218
- if (e.eventId <= since) continue
219
- if (!deliverable(s, e.table, e.record, e.record['id'])) continue
220
- s.send(
221
- JSON.stringify({
222
- eventId: e.eventId,
223
- action: e.action,
224
- table: e.table,
225
- record: e.record,
226
- }),
227
- )
228
- }
229
- return { gap }
230
- },
231
- unregister(id) {
232
- subscribers.delete(id)
233
- },
234
- publish(table, action, record) {
235
- const entry = tableEntry(opts.access, table)
236
- if (!entry) return
237
- const eventId = nextId++
238
- buffer.push({ eventId, table, action, record })
239
- if (buffer.length > bufferSize) buffer.shift()
240
- const id = record['id']
241
- const payload = JSON.stringify({ eventId, action, table, record })
242
- for (const s of subscribers.values()) {
243
- if (!deliverable(s, table, record, id)) continue
244
- s.send(payload)
245
- }
246
- },
247
- }
248
- }
249
-
250
- export const createMemoryRealtimeBroker = createRealtimeBroker