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/worker.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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:
|
|
46
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
156
|
+
const def = definitionFor(defs, row.type)
|
|
84
157
|
const error = new Error('lease expired (worker crashed or timed out)')
|
|
85
|
-
if (!def
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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<
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
|
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 =
|
|
48
|
-
const migrationDirectory =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
(
|
|
52
|
-
value.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 =
|
|
59
|
-
|
|
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 =
|
|
71
|
-
.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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,
|
|
152
|
+
section: Record<string, StandardSchemaV1> | undefined,
|
|
166
153
|
scope: ManifestEnvVar['scope'],
|
|
167
154
|
): ManifestEnvVar[] {
|
|
168
|
-
return Object.entries(section ?? {}).map(([key, schema]) =>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 =
|
|
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),
|
package/src/realtime/facade.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { getTableName, type InferSelectModel, type Table } from 'drizzle-orm'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
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
|
-
|
|
27
|
-
transport: RealtimeTransport =
|
|
29
|
+
publisher?: RealtimePublisher,
|
|
30
|
+
transport: RealtimeTransport = publisher ? 'memory' : 'disabled',
|
|
28
31
|
): RealtimeFacade<TSchema> {
|
|
29
|
-
if (!
|
|
32
|
+
if (!publisher && transport !== 'disabled') {
|
|
30
33
|
throw new Error(
|
|
31
|
-
'[bunderstack] an enabled realtime transport requires a
|
|
34
|
+
'[bunderstack] an enabled realtime transport requires a publisher',
|
|
32
35
|
)
|
|
33
36
|
}
|
|
34
|
-
if (
|
|
37
|
+
if (publisher && transport === 'disabled') {
|
|
35
38
|
throw new Error(
|
|
36
|
-
'[bunderstack] a realtime
|
|
39
|
+
'[bunderstack] a realtime publisher cannot use the disabled transport',
|
|
37
40
|
)
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
return {
|
|
41
|
-
enabled:
|
|
44
|
+
enabled: publisher !== undefined,
|
|
42
45
|
transport,
|
|
43
46
|
async publish(table, action, record) {
|
|
44
|
-
if (!
|
|
45
|
-
await
|
|
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
|
+
}
|