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,228 +0,0 @@
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
- close?(): void | Promise<void>
31
- }
32
-
33
- type Subscriber = {
34
- id: string
35
- send: (data: string) => void
36
- user: AccessUser | null
37
- activeOrganizationId: string | null
38
- subscriptions: Set<string>
39
- }
40
-
41
- type WireEvent = {
42
- eventId: number
43
- table: string
44
- action: RealtimeAction
45
- record: Record<string, unknown>
46
- }
47
-
48
- function tableEntry(
49
- access: ResolvedAccess,
50
- name: string,
51
- ): ResolvedTableAccess | undefined {
52
- for (const entry of access.values())
53
- if (entry.tableName === name) return entry
54
- return undefined
55
- }
56
-
57
- function isRecord(value: unknown): value is Record<string, unknown> {
58
- return value !== null && typeof value === 'object' && !Array.isArray(value)
59
- }
60
-
61
- function isRealtimeAction(value: unknown): value is RealtimeAction {
62
- return value === 'create' || value === 'update' || value === 'delete'
63
- }
64
-
65
- function parseWireEvent(raw: string): WireEvent | null {
66
- try {
67
- const value = JSON.parse(raw)
68
- if (
69
- !isRecord(value) ||
70
- typeof value.eventId !== 'number' ||
71
- typeof value.table !== 'string' ||
72
- !isRealtimeAction(value.action) ||
73
- !isRecord(value.record)
74
- ) {
75
- return null
76
- }
77
- return {
78
- eventId: value.eventId,
79
- table: value.table,
80
- action: value.action,
81
- record: value.record,
82
- }
83
- } catch {
84
- return null
85
- }
86
- }
87
-
88
- export function createRedisRealtimeBroker(opts: {
89
- access: ResolvedAccess
90
- /** A factory keeps Redis completely cold until SSE or a publish needs it. */
91
- redis: RedisLike | (() => RedisLike)
92
- bufferSize?: number
93
- channel?: string
94
- }): RealtimeBroker {
95
- const subscribers = new Map<string, Subscriber>()
96
- const bufferSize = opts.bufferSize ?? 1000
97
- const channel = opts.channel ?? 'bunderstack:realtime'
98
- const logKey = `${channel}:log`
99
- const counterKey = `${channel}:seq`
100
- let redis: RedisLike | undefined
101
-
102
- const getRedis = () => {
103
- redis ??= typeof opts.redis === 'function' ? opts.redis() : opts.redis
104
- return redis
105
- }
106
-
107
- function deliverable(
108
- s: Subscriber,
109
- table: string,
110
- record: Record<string, unknown>,
111
- ): boolean {
112
- const entry = tableEntry(opts.access, table)
113
- if (!entry) return false
114
- const id = record['id']
115
- const topicMatch =
116
- s.subscriptions.has(table) ||
117
- (id != null && s.subscriptions.has(`${table}/${String(id)}`))
118
- if (!topicMatch) return false
119
- const ctx = {
120
- user: s.user,
121
- request: new Request('http://realtime.local'),
122
- row: record,
123
- session: { activeOrganizationId: s.activeOrganizationId },
124
- }
125
- if (typeof entry.get === 'function') return false
126
- if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
127
- return false
128
- if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx)))
129
- return false
130
- return true
131
- }
132
-
133
- function fanOut(evt: WireEvent) {
134
- const payload = JSON.stringify(evt)
135
- for (const s of subscribers.values()) {
136
- if (deliverable(s, evt.table, evt.record)) s.send(payload)
137
- }
138
- }
139
-
140
- let started: Promise<void> | undefined
141
- let closed = false
142
-
143
- const start = () => {
144
- if (closed)
145
- return Promise.reject(
146
- new Error('[bunderstack] realtime broker is closed'),
147
- )
148
- started ??= getRedis()
149
- .subscribe(channel, (message) => {
150
- const evt = parseWireEvent(message)
151
- if (evt) fanOut(evt)
152
- })
153
- .then(() => undefined)
154
- return started
155
- }
156
-
157
- return {
158
- start,
159
- async close() {
160
- closed = true
161
- subscribers.clear()
162
- await redis?.close?.()
163
- },
164
- register(send) {
165
- const id = crypto.randomUUID()
166
- subscribers.set(id, {
167
- id,
168
- send,
169
- user: null,
170
- activeOrganizationId: null,
171
- subscriptions: new Set(),
172
- })
173
- return { id }
174
- },
175
- async setContext(id, ctx) {
176
- const s = subscribers.get(id)
177
- if (!s) return { gap: false }
178
- s.user = ctx.user
179
- s.activeOrganizationId = ctx.activeOrganizationId
180
- s.subscriptions = ctx.subscriptions
181
-
182
- const since = ctx.since ?? null
183
- if (since == null) return { gap: false }
184
-
185
- // Log is LPUSH-ed (newest first); read newest->oldest, filter id>since.
186
- // If redis is unavailable, return { gap: true } so the client falls back
187
- // to a full refetch rather than 500-ing the POST handler.
188
- let raw: string[]
189
- try {
190
- raw = await getRedis().lrange(logKey, 0, bufferSize - 1)
191
- } catch {
192
- return { gap: true }
193
- }
194
- const events = raw
195
- .map(parseWireEvent)
196
- .filter((e) => e !== null)
197
- .filter((e) => e.eventId > since)
198
- .sort((a, b) => a.eventId - b.eventId)
199
-
200
- const oldestInLog = events.length ? events[0]!.eventId : since + 1
201
- const gap = oldestInLog > since + 1
202
- for (const e of events) {
203
- if (deliverable(s, e.table, e.record)) s.send(JSON.stringify(e))
204
- }
205
- return { gap }
206
- },
207
- unregister(id) {
208
- subscribers.delete(id)
209
- },
210
- async publish(table, action, record) {
211
- if (!tableEntry(opts.access, table)) return
212
- try {
213
- const client = getRedis()
214
- const eventId = await client.incr(counterKey)
215
- const evt: WireEvent = { eventId, table, action, record }
216
- const msg = JSON.stringify(evt)
217
- await client.lpush(logKey, msg)
218
- await client.ltrim(logKey, 0, bufferSize - 1)
219
- await client.publish(channel, msg)
220
- } catch {
221
- // Broadcast is best-effort: a redis blip must not reject the floating
222
- // promise (callers use `void broker?.publish(...)` with no .catch).
223
- // Correctness self-heals: reconnecting clients use the since/gap replay
224
- // path which issues a full refetch when events were missed.
225
- }
226
- },
227
- }
228
- }