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.
- package/README.md +25 -138
- package/package.json +22 -14
- package/src/access.ts +24 -1
- package/src/api/api-types.types.ts +106 -0
- package/src/api/builder.ts +52 -0
- package/src/api/context.ts +83 -0
- package/src/api/crud-router.ts +321 -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 +84 -0
- package/src/auth.ts +5 -0
- package/src/blueprint.ts +88 -105
- package/src/config.ts +73 -77
- package/src/cron.ts +2 -1
- package/src/crud-operations.ts +488 -0
- package/src/dialect.ts +1 -1
- package/src/env.ts +28 -21
- package/src/errors.ts +90 -23
- package/src/handler.ts +16 -44
- package/src/index.ts +283 -294
- package/src/internal-tables-pg.ts +1 -17
- package/src/internal-tables.ts +0 -31
- package/src/jobs/define.ts +75 -21
- package/src/jobs/index.ts +3 -9
- package/src/jobs/queue.ts +14 -6
- package/src/jobs/slots.ts +52 -0
- package/src/jobs/worker.ts +142 -42
- package/src/manifest.ts +84 -93
- package/src/realtime/facade.ts +16 -13
- package/src/realtime/filter.ts +77 -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 -408
- package/src/jobs/cron-auth.ts +0 -28
- package/src/jobs/cron-router.ts +0 -135
- package/src/jobs/cron-runner.ts +0 -224
- package/src/jobs/local-cron.ts +0 -78
- package/src/realtime/index.ts +0 -250
- package/src/realtime/redis.ts +0 -228
- package/src/storage/router.ts +0 -531
- package/src/trpc.ts +0 -57
package/src/realtime/redis.ts
DELETED
|
@@ -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
|
-
}
|