bunderstack 0.4.0 → 0.6.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 +21 -2
- package/package.json +3 -2
- package/src/access.ts +9 -4
- package/src/cron.ts +2 -0
- package/src/crud.ts +16 -16
- package/src/env.ts +7 -0
- package/src/handler.ts +5 -0
- package/src/index.ts +175 -29
- package/src/internal-tables-pg.ts +18 -0
- package/src/internal-tables.ts +28 -0
- package/src/jobs/cron-auth.ts +28 -0
- package/src/jobs/cron-router.ts +131 -0
- package/src/jobs/cron-runner.ts +112 -0
- package/src/jobs/define.ts +58 -22
- package/src/jobs/index.ts +18 -0
- package/src/jobs/local-cron.ts +76 -0
- package/src/jobs/queue.ts +3 -1
- package/src/jobs/runtime.ts +58 -0
- package/src/jobs/worker.ts +13 -37
- package/src/lifecycle.ts +44 -0
- package/src/manifest.ts +46 -6
- package/src/realtime/index.ts +8 -3
- package/src/realtime/redis.ts +36 -16
- package/src/storage/buckets.ts +8 -3
- package/src/storage/router.ts +4 -4
package/src/jobs/worker.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
// src/jobs/worker.ts — the
|
|
2
|
-
// recover expired leases →
|
|
3
|
-
// claim and run claimable jobs (awaiting handlers, so tests drive `tick()`
|
|
4
|
-
// deterministically with an injected `now`). Multiple replicas run the same
|
|
5
|
-
// loop safely: claims are atomic and cron slots dedupe on a unique index.
|
|
1
|
+
// src/jobs/worker.ts — the queue worker. One `tick()` is a full cycle:
|
|
2
|
+
// recover expired leases → reap old succeeded rows → claim and run queue jobs.
|
|
6
3
|
import { and, eq, inArray, is, isNotNull, lt, lte, sql } from 'drizzle-orm'
|
|
7
4
|
import { PgDatabase } from 'drizzle-orm/pg-core'
|
|
8
5
|
|
|
@@ -12,12 +9,9 @@ import type {
|
|
|
12
9
|
JobsDefs,
|
|
13
10
|
JobsRuntimeFacade,
|
|
14
11
|
} from './define'
|
|
15
|
-
import type { ParsedCron } from './cron'
|
|
16
12
|
|
|
17
13
|
import { jobsTableFor } from '../internal-tables'
|
|
18
|
-
import { cronMatches, parseCron } from './cron'
|
|
19
14
|
import { backoffMs, DEFAULT_RETRIES, DEFAULT_TIMEOUT_MS } from './define'
|
|
20
|
-
import { enqueueJob } from './queue'
|
|
21
15
|
|
|
22
16
|
const CLAIM_BATCH = 10
|
|
23
17
|
const SUCCEEDED_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
@@ -37,9 +31,9 @@ function maxAttempts(def: AnyJobDefinition): number {
|
|
|
37
31
|
return 1 + (def.retries ?? DEFAULT_RETRIES)
|
|
38
32
|
}
|
|
39
33
|
|
|
40
|
-
/** Terminal
|
|
41
|
-
function terminalPatch(
|
|
42
|
-
return
|
|
34
|
+
/** Terminal queue rows release their dedupe key. */
|
|
35
|
+
function terminalPatch() {
|
|
36
|
+
return { dedupeKey: null }
|
|
43
37
|
}
|
|
44
38
|
|
|
45
39
|
export function createJobRunner(deps: {
|
|
@@ -51,11 +45,6 @@ export function createJobRunner(deps: {
|
|
|
51
45
|
const { db, defs } = deps
|
|
52
46
|
const t = jobsTableFor(db)
|
|
53
47
|
const ctx = { ...deps.ctx } as Record<string, unknown>
|
|
54
|
-
const crons = new Map<string, ParsedCron>()
|
|
55
|
-
for (const [name, def] of Object.entries(defs)) {
|
|
56
|
-
if (def.cron) crons.set(name, parseCron(def.cron))
|
|
57
|
-
}
|
|
58
|
-
|
|
59
48
|
async function fireOnFailed(
|
|
60
49
|
def: AnyJobDefinition,
|
|
61
50
|
payloadJson: string,
|
|
@@ -93,7 +82,7 @@ export function createJobRunner(deps: {
|
|
|
93
82
|
for (const row of expired) {
|
|
94
83
|
const def = defs[row.type]
|
|
95
84
|
const error = new Error('lease expired (worker crashed or timed out)')
|
|
96
|
-
if (!def) {
|
|
85
|
+
if (!def || def.kind !== 'job') {
|
|
97
86
|
await db
|
|
98
87
|
.update(t)
|
|
99
88
|
.set({
|
|
@@ -114,7 +103,7 @@ export function createJobRunner(deps: {
|
|
|
114
103
|
finishedAt: now,
|
|
115
104
|
lockedUntil: null,
|
|
116
105
|
lastError: error.message,
|
|
117
|
-
...terminalPatch(
|
|
106
|
+
...terminalPatch(),
|
|
118
107
|
})
|
|
119
108
|
.where(eq(t.id, row.id))
|
|
120
109
|
await fireOnFailed(def, row.payloadJson, error)
|
|
@@ -132,20 +121,6 @@ export function createJobRunner(deps: {
|
|
|
132
121
|
}
|
|
133
122
|
}
|
|
134
123
|
|
|
135
|
-
/** Enqueue the current minute's slot for every cron definition. */
|
|
136
|
-
async function scheduleCronSlots(now: number) {
|
|
137
|
-
const minute = Math.floor(now / 60_000) * 60_000
|
|
138
|
-
for (const [name, cron] of crons) {
|
|
139
|
-
if (!cronMatches(cron, minute)) continue
|
|
140
|
-
// The unique (type, dedupe_key) index collapses concurrent replicas'
|
|
141
|
-
// enqueues of the same slot into one row.
|
|
142
|
-
await enqueueJob(db, defs, name, undefined, {
|
|
143
|
-
dedupeKey: `cron:${name}:${minute}`,
|
|
144
|
-
runAt: minute,
|
|
145
|
-
})
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
124
|
async function reapSucceeded(now: number) {
|
|
150
125
|
await db
|
|
151
126
|
.delete(t)
|
|
@@ -211,7 +186,7 @@ export function createJobRunner(deps: {
|
|
|
211
186
|
finishedAt: Date.now(),
|
|
212
187
|
lockedUntil: null,
|
|
213
188
|
lastError: e.message,
|
|
214
|
-
...terminalPatch(
|
|
189
|
+
...terminalPatch(),
|
|
215
190
|
})
|
|
216
191
|
.where(eq(t.id, row.id))
|
|
217
192
|
await fireOnFailed(def, row.payloadJson, e)
|
|
@@ -225,7 +200,7 @@ export function createJobRunner(deps: {
|
|
|
225
200
|
status: 'succeeded',
|
|
226
201
|
finishedAt: Date.now(),
|
|
227
202
|
lockedUntil: null,
|
|
228
|
-
...terminalPatch(
|
|
203
|
+
...terminalPatch(),
|
|
229
204
|
})
|
|
230
205
|
.where(eq(t.id, row.id))
|
|
231
206
|
} catch (err) {
|
|
@@ -248,7 +223,7 @@ export function createJobRunner(deps: {
|
|
|
248
223
|
finishedAt: Date.now(),
|
|
249
224
|
lockedUntil: null,
|
|
250
225
|
lastError: e.message,
|
|
251
|
-
...terminalPatch(
|
|
226
|
+
...terminalPatch(),
|
|
252
227
|
})
|
|
253
228
|
.where(eq(t.id, row.id))
|
|
254
229
|
await fireOnFailed(def, row.payloadJson, e)
|
|
@@ -258,7 +233,9 @@ export function createJobRunner(deps: {
|
|
|
258
233
|
|
|
259
234
|
async function runClaimable(now: number) {
|
|
260
235
|
const work: Promise<void>[] = []
|
|
261
|
-
for (const [type,
|
|
236
|
+
for (const [type, candidate] of Object.entries(defs)) {
|
|
237
|
+
if (candidate.kind !== 'job') continue
|
|
238
|
+
const def = candidate
|
|
262
239
|
let limit = CLAIM_BATCH
|
|
263
240
|
if (def.concurrency !== undefined) {
|
|
264
241
|
const runningRows = await db
|
|
@@ -279,7 +256,6 @@ export function createJobRunner(deps: {
|
|
|
279
256
|
return {
|
|
280
257
|
async tick(now: number = Date.now()) {
|
|
281
258
|
await recoverExpiredLeases(now)
|
|
282
|
-
await scheduleCronSlots(now)
|
|
283
259
|
await reapSucceeded(now)
|
|
284
260
|
await runClaimable(now)
|
|
285
261
|
},
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type Cleanup = () => void | Promise<void>
|
|
2
|
+
export type LifecycleStatus = 'ready' | 'closing' | 'closed'
|
|
3
|
+
|
|
4
|
+
export class Lifecycle {
|
|
5
|
+
#controller = new AbortController()
|
|
6
|
+
#status: LifecycleStatus = 'ready'
|
|
7
|
+
#cleanups = new Set<Cleanup>()
|
|
8
|
+
#closePromise: Promise<void> | undefined
|
|
9
|
+
|
|
10
|
+
get signal(): AbortSignal {
|
|
11
|
+
return this.#controller.signal
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get status(): LifecycleStatus {
|
|
15
|
+
return this.#status
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
add(cleanup: Cleanup): () => void {
|
|
19
|
+
if (this.#status !== 'ready') {
|
|
20
|
+
throw new Error('[bunderstack] application lifecycle is closed')
|
|
21
|
+
}
|
|
22
|
+
this.#cleanups.add(cleanup)
|
|
23
|
+
return () => this.#cleanups.delete(cleanup)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
close(): Promise<void> {
|
|
27
|
+
if (this.#closePromise) return this.#closePromise
|
|
28
|
+
this.#status = 'closing'
|
|
29
|
+
this.#controller.abort()
|
|
30
|
+
this.#closePromise = (async () => {
|
|
31
|
+
const cleanups = [...this.#cleanups].reverse()
|
|
32
|
+
this.#cleanups.clear()
|
|
33
|
+
const results = await Promise.allSettled(cleanups.map((cleanup) => cleanup()))
|
|
34
|
+
this.#status = 'closed'
|
|
35
|
+
const errors = results.flatMap((result) =>
|
|
36
|
+
result.status === 'rejected' ? [result.reason] : [],
|
|
37
|
+
)
|
|
38
|
+
if (errors.length > 0) {
|
|
39
|
+
throw new AggregateError(errors, '[bunderstack] lifecycle cleanup failed')
|
|
40
|
+
}
|
|
41
|
+
})()
|
|
42
|
+
return this.#closePromise
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/manifest.ts
CHANGED
|
@@ -3,23 +3,46 @@
|
|
|
3
3
|
// Deployment platforms (Bunderhost) import the app declaration with
|
|
4
4
|
// BUNDERSTACK_INTROSPECT=1 and read `app.manifest` to learn what to provision.
|
|
5
5
|
import type { ZodType } from 'zod'
|
|
6
|
+
import { getTableName, isTable } from 'drizzle-orm'
|
|
6
7
|
|
|
7
8
|
import type { Dialect } from './dialect'
|
|
8
9
|
import type { EnvConfigInput } from './env'
|
|
9
10
|
import type { JobsDefs } from './jobs/define'
|
|
10
11
|
import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
|
|
12
|
+
import {
|
|
13
|
+
bunderstackCronRuns,
|
|
14
|
+
bunderstackFiles,
|
|
15
|
+
bunderstackJobs,
|
|
16
|
+
} from './internal-tables'
|
|
11
17
|
|
|
12
18
|
export type ManifestEnvVar = { key: string; required: boolean }
|
|
13
|
-
export type ManifestJob = { name: string; cron?: string }
|
|
14
|
-
|
|
15
19
|
export type BunderstackManifest = {
|
|
20
|
+
version: 2
|
|
16
21
|
dialect: Dialect
|
|
17
22
|
tables: string[]
|
|
23
|
+
tableMap: Record<string, string>
|
|
24
|
+
systemTables: {
|
|
25
|
+
jobs: string
|
|
26
|
+
files: string
|
|
27
|
+
scheduledRuns: string
|
|
28
|
+
}
|
|
18
29
|
defaultBucket: string
|
|
19
30
|
buckets: { name: string; visibility: ResolvedBucket['visibility'] }[]
|
|
20
31
|
realtime: boolean
|
|
21
32
|
env: { server: ManifestEnvVar[]; client: ManifestEnvVar[] }
|
|
22
|
-
|
|
33
|
+
background: {
|
|
34
|
+
jobs: { name: string }[]
|
|
35
|
+
cron: { name: string; schedule: string; timezone: 'UTC' }[]
|
|
36
|
+
maintenance: { name: 'storage-sweep'; schedule: string }[]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function describeTables(schema: Record<string, unknown>): Record<string, string> {
|
|
41
|
+
return Object.fromEntries(
|
|
42
|
+
Object.entries(schema).flatMap(([key, value]) =>
|
|
43
|
+
isTable(value) ? [[key, getTableName(value)]] : [],
|
|
44
|
+
),
|
|
45
|
+
)
|
|
23
46
|
}
|
|
24
47
|
|
|
25
48
|
function describeSection(
|
|
@@ -40,8 +63,15 @@ export function buildManifest(args: {
|
|
|
40
63
|
jobs: JobsDefs | undefined
|
|
41
64
|
}): BunderstackManifest {
|
|
42
65
|
return {
|
|
66
|
+
version: 2,
|
|
43
67
|
dialect: args.dialect,
|
|
44
68
|
tables: Object.keys(args.schema),
|
|
69
|
+
tableMap: describeTables(args.schema),
|
|
70
|
+
systemTables: {
|
|
71
|
+
jobs: getTableName(bunderstackJobs),
|
|
72
|
+
files: getTableName(bunderstackFiles),
|
|
73
|
+
scheduledRuns: getTableName(bunderstackCronRuns),
|
|
74
|
+
},
|
|
45
75
|
defaultBucket: args.storage.defaultBucket,
|
|
46
76
|
buckets: [...args.storage.buckets.values()].map((bucket) => ({
|
|
47
77
|
name: bucket.name,
|
|
@@ -52,8 +82,18 @@ export function buildManifest(args: {
|
|
|
52
82
|
server: describeSection(args.envConfig?.server),
|
|
53
83
|
client: describeSection(args.envConfig?.client),
|
|
54
84
|
},
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
85
|
+
background: {
|
|
86
|
+
jobs: Object.entries(args.jobs ?? {})
|
|
87
|
+
.filter(([, def]) => def.kind === 'job')
|
|
88
|
+
.map(([name]) => ({ name })),
|
|
89
|
+
cron: Object.entries(args.jobs ?? {})
|
|
90
|
+
.filter(([, def]) => def.kind === 'cron')
|
|
91
|
+
.map(([name, def]) => ({
|
|
92
|
+
name,
|
|
93
|
+
schedule: def.kind === 'cron' ? def.schedule : '',
|
|
94
|
+
timezone: 'UTC' as const,
|
|
95
|
+
})),
|
|
96
|
+
maintenance: [{ name: 'storage-sweep', schedule: '0 4 * * *' }],
|
|
97
|
+
},
|
|
58
98
|
}
|
|
59
99
|
}
|
package/src/realtime/index.ts
CHANGED
|
@@ -35,6 +35,8 @@ type RealtimeContextBody = {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export type RealtimeBroker = {
|
|
38
|
+
start(): Promise<void>
|
|
39
|
+
close(): Promise<void>
|
|
38
40
|
register(send: (data: string) => void): { id: string }
|
|
39
41
|
setContext(
|
|
40
42
|
id: string,
|
|
@@ -84,8 +86,8 @@ function scopeOk(
|
|
|
84
86
|
ctx: Parameters<typeof checkAccessSync>[1],
|
|
85
87
|
record: Record<string, unknown>,
|
|
86
88
|
): boolean {
|
|
87
|
-
if (!entry.
|
|
88
|
-
return rowMatchesScope(record, entry.
|
|
89
|
+
if (!entry.readScope) return true
|
|
90
|
+
return rowMatchesScope(record, entry.readScope(ctx))
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
export function buildRealtimeRouter(
|
|
@@ -101,7 +103,8 @@ export function buildRealtimeRouter(
|
|
|
101
103
|
let keepalive: ReturnType<typeof setInterval>
|
|
102
104
|
|
|
103
105
|
const stream = new ReadableStream({
|
|
104
|
-
start(controller) {
|
|
106
|
+
async start(controller) {
|
|
107
|
+
await broker.start()
|
|
105
108
|
const send = (data: string) =>
|
|
106
109
|
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
107
110
|
handle = broker.register(send)
|
|
@@ -183,6 +186,8 @@ export function createRealtimeBroker(opts: {
|
|
|
183
186
|
}
|
|
184
187
|
|
|
185
188
|
return {
|
|
189
|
+
async start() {},
|
|
190
|
+
async close() {},
|
|
186
191
|
register(send) {
|
|
187
192
|
const id = crypto.randomUUID()
|
|
188
193
|
subscribers.set(id, {
|
package/src/realtime/redis.ts
CHANGED
|
@@ -27,6 +27,7 @@ export type RedisLike = {
|
|
|
27
27
|
lpush(key: string, value: string): Promise<unknown>
|
|
28
28
|
ltrim(key: string, start: number, stop: number): Promise<unknown>
|
|
29
29
|
lrange(key: string, start: number, stop: number): Promise<string[]>
|
|
30
|
+
close?(): void | Promise<void>
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
type Subscriber = {
|
|
@@ -86,15 +87,22 @@ function parseWireEvent(raw: string): WireEvent | null {
|
|
|
86
87
|
|
|
87
88
|
export function createRedisRealtimeBroker(opts: {
|
|
88
89
|
access: ResolvedAccess
|
|
89
|
-
|
|
90
|
+
/** A factory keeps Redis completely cold until SSE or a publish needs it. */
|
|
91
|
+
redis: RedisLike | (() => RedisLike)
|
|
90
92
|
bufferSize?: number
|
|
91
93
|
channel?: string
|
|
92
|
-
}): RealtimeBroker
|
|
94
|
+
}): RealtimeBroker {
|
|
93
95
|
const subscribers = new Map<string, Subscriber>()
|
|
94
96
|
const bufferSize = opts.bufferSize ?? 1000
|
|
95
97
|
const channel = opts.channel ?? 'bunderstack:realtime'
|
|
96
98
|
const logKey = `${channel}:log`
|
|
97
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
|
+
}
|
|
98
106
|
|
|
99
107
|
function deliverable(
|
|
100
108
|
s: Subscriber,
|
|
@@ -117,7 +125,7 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
117
125
|
if (typeof entry.get === 'function') return false
|
|
118
126
|
if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
|
|
119
127
|
return false
|
|
120
|
-
if (entry.
|
|
128
|
+
if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx))) return false
|
|
121
129
|
return true
|
|
122
130
|
}
|
|
123
131
|
|
|
@@ -128,16 +136,27 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
128
136
|
}
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
let started: Promise<void> | undefined
|
|
140
|
+
let closed = false
|
|
141
|
+
|
|
142
|
+
const start = () => {
|
|
143
|
+
if (closed) return Promise.reject(new Error('[bunderstack] realtime broker is closed'))
|
|
144
|
+
started ??= getRedis()
|
|
145
|
+
.subscribe(channel, (message) => {
|
|
146
|
+
const evt = parseWireEvent(message)
|
|
147
|
+
if (evt) fanOut(evt)
|
|
148
|
+
})
|
|
149
|
+
.then(() => undefined)
|
|
150
|
+
return started
|
|
151
|
+
}
|
|
138
152
|
|
|
139
153
|
return {
|
|
140
|
-
|
|
154
|
+
start,
|
|
155
|
+
async close() {
|
|
156
|
+
closed = true
|
|
157
|
+
subscribers.clear()
|
|
158
|
+
await redis?.close?.()
|
|
159
|
+
},
|
|
141
160
|
register(send) {
|
|
142
161
|
const id = crypto.randomUUID()
|
|
143
162
|
subscribers.set(id, {
|
|
@@ -164,7 +183,7 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
164
183
|
// to a full refetch rather than 500-ing the POST handler.
|
|
165
184
|
let raw: string[]
|
|
166
185
|
try {
|
|
167
|
-
raw = await
|
|
186
|
+
raw = await getRedis().lrange(logKey, 0, bufferSize - 1)
|
|
168
187
|
} catch {
|
|
169
188
|
return { gap: true }
|
|
170
189
|
}
|
|
@@ -187,12 +206,13 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
187
206
|
async publish(table, action, record) {
|
|
188
207
|
if (!tableEntry(opts.access, table)) return
|
|
189
208
|
try {
|
|
190
|
-
const
|
|
209
|
+
const client = getRedis()
|
|
210
|
+
const eventId = await client.incr(counterKey)
|
|
191
211
|
const evt: WireEvent = { eventId, table, action, record }
|
|
192
212
|
const msg = JSON.stringify(evt)
|
|
193
|
-
await
|
|
194
|
-
await
|
|
195
|
-
await
|
|
213
|
+
await client.lpush(logKey, msg)
|
|
214
|
+
await client.ltrim(logKey, 0, bufferSize - 1)
|
|
215
|
+
await client.publish(channel, msg)
|
|
196
216
|
} catch {
|
|
197
217
|
// Broadcast is best-effort: a redis blip must not reject the floating
|
|
198
218
|
// promise (callers use `void broker?.publish(...)` with no .catch).
|
package/src/storage/buckets.ts
CHANGED
|
@@ -25,7 +25,10 @@ export type BucketConfigInput = {
|
|
|
25
25
|
}
|
|
26
26
|
upload?: { maxSize?: string | number; accept?: string[] }
|
|
27
27
|
transforms?: boolean
|
|
28
|
-
scope?:
|
|
28
|
+
scope?: {
|
|
29
|
+
read?: ScopeResolver
|
|
30
|
+
write?: ScopeResolver
|
|
31
|
+
}
|
|
29
32
|
quota?: { perUser?: string | number; perScope?: string | number }
|
|
30
33
|
} & Partial<BucketBackendInput>
|
|
31
34
|
|
|
@@ -59,7 +62,8 @@ export type ResolvedBucket = {
|
|
|
59
62
|
access: { create: OperationRule; get: OperationRule; delete: OperationRule }
|
|
60
63
|
upload?: { maxSizeBytes?: number; accept?: string[] }
|
|
61
64
|
transforms: boolean
|
|
62
|
-
|
|
65
|
+
readScope?: ScopeResolver
|
|
66
|
+
writeScope?: ScopeResolver
|
|
63
67
|
quota?: { perUserBytes?: number; perScopeBytes?: number }
|
|
64
68
|
}
|
|
65
69
|
|
|
@@ -248,7 +252,8 @@ function resolveSingleBucket(
|
|
|
248
252
|
access,
|
|
249
253
|
upload,
|
|
250
254
|
transforms: input.transforms ?? false,
|
|
251
|
-
|
|
255
|
+
readScope: input.scope?.read,
|
|
256
|
+
writeScope: input.scope?.write,
|
|
252
257
|
quota,
|
|
253
258
|
}
|
|
254
259
|
}
|
package/src/storage/router.ts
CHANGED
|
@@ -165,7 +165,7 @@ export function buildBucketStorageRouter(
|
|
|
165
165
|
const contentType =
|
|
166
166
|
typeof body.contentType === 'string' ? body.contentType : undefined
|
|
167
167
|
|
|
168
|
-
const requesterScope = bucket.
|
|
168
|
+
const requesterScope = bucket.writeScope?.(ctx)
|
|
169
169
|
const scopeJson = scopeToJson(requesterScope)
|
|
170
170
|
|
|
171
171
|
// Quota pre-check: reserve the configured max upload size.
|
|
@@ -252,7 +252,7 @@ export function buildBucketStorageRouter(
|
|
|
252
252
|
return apiError(c, ErrorCode.VALIDATION_ERROR, 'File too large', 422)
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
const requesterScope = bucket.
|
|
255
|
+
const requesterScope = bucket.readScope?.(ctx)
|
|
256
256
|
const scopeJson = scopeToJson(requesterScope)
|
|
257
257
|
|
|
258
258
|
if (bucket.quota) {
|
|
@@ -395,7 +395,7 @@ export function buildBucketStorageRouter(
|
|
|
395
395
|
const denied = await gate(bucket.access.get, ctx, c)
|
|
396
396
|
if (denied) return denied
|
|
397
397
|
|
|
398
|
-
const requesterScope = bucket.
|
|
398
|
+
const requesterScope = bucket.readScope?.(ctx)
|
|
399
399
|
if (!fileMatchesScope(row, requesterScope)) {
|
|
400
400
|
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
401
401
|
}
|
|
@@ -491,7 +491,7 @@ export function buildBucketStorageRouter(
|
|
|
491
491
|
const denied = await gate(bucket.access.delete, ctx, c)
|
|
492
492
|
if (denied) return denied
|
|
493
493
|
|
|
494
|
-
const requesterScope = bucket.
|
|
494
|
+
const requesterScope = bucket.readScope?.(ctx)
|
|
495
495
|
if (!fileMatchesScope(row, requesterScope)) {
|
|
496
496
|
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
497
497
|
}
|