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