bunderstack 0.15.0 → 0.15.2
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/package.json +1 -1
- package/src/blueprint-generator.ts +63 -17
- package/src/blueprint.ts +123 -30
- package/src/cli.ts +10 -2
- package/src/crud.ts +27 -5
- package/src/env.ts +12 -5
- package/src/internal-tables.ts +4 -1
- package/src/jobs/cron-router.ts +11 -7
- package/src/jobs/cron-runner.ts +68 -28
- package/src/jobs/index.ts +5 -1
- package/src/jobs/worker.ts +11 -8
- package/src/list-query.ts +2 -4
- package/src/manifest.ts +68 -18
- package/src/storage/buckets.ts +2 -1
- package/src/storage/file-meta.ts +1 -1
- package/src/storage/router.ts +2 -6
- package/src/storage/s3.ts +9 -3
- package/src/trpc.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
4
4
|
"description": "Batteries-included backend framework for Bun: CRUD APIs, auth, file storage, realtime, tRPC, email, and validated env from a single Drizzle schema and config object.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"backend",
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
mkdir,
|
|
3
|
+
readFile,
|
|
4
|
+
realpath,
|
|
5
|
+
rename,
|
|
6
|
+
rm,
|
|
7
|
+
writeFile,
|
|
8
|
+
} from 'node:fs/promises'
|
|
2
9
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
3
10
|
import { pathToFileURL } from 'node:url'
|
|
4
11
|
|
|
@@ -25,7 +32,9 @@ export type GenerateBlueprintResult = {
|
|
|
25
32
|
|
|
26
33
|
export class BlueprintCheckError extends Error {
|
|
27
34
|
constructor() {
|
|
28
|
-
super(
|
|
35
|
+
super(
|
|
36
|
+
'bunderstack.blueprint.yaml is missing or stale; run `bunderstack blueprint`',
|
|
37
|
+
)
|
|
29
38
|
this.name = 'BlueprintCheckError'
|
|
30
39
|
}
|
|
31
40
|
}
|
|
@@ -44,7 +53,9 @@ function requireRelativePath(value: string, label: string): string {
|
|
|
44
53
|
isAbsolute(normalized) ||
|
|
45
54
|
normalized.split('/').some((part) => !part || part === '..')
|
|
46
55
|
) {
|
|
47
|
-
throw new Error(
|
|
56
|
+
throw new Error(
|
|
57
|
+
`[bunderstack] ${label} must be a relative path without traversal`,
|
|
58
|
+
)
|
|
48
59
|
}
|
|
49
60
|
return normalized
|
|
50
61
|
}
|
|
@@ -52,27 +63,46 @@ function requireRelativePath(value: string, label: string): string {
|
|
|
52
63
|
function resolveWithin(root: string, value: string, label: string): string {
|
|
53
64
|
const path = resolve(root, requireRelativePath(value, label))
|
|
54
65
|
const pathFromRoot = relative(root, path)
|
|
55
|
-
if (
|
|
56
|
-
|
|
66
|
+
if (
|
|
67
|
+
pathFromRoot === '..' ||
|
|
68
|
+
pathFromRoot.startsWith('../') ||
|
|
69
|
+
isAbsolute(pathFromRoot)
|
|
70
|
+
) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`[bunderstack] ${label} must stay within the application directory`,
|
|
73
|
+
)
|
|
57
74
|
}
|
|
58
75
|
return path
|
|
59
76
|
}
|
|
60
77
|
|
|
61
|
-
function normalizeProjectPath(
|
|
78
|
+
function normalizeProjectPath(
|
|
79
|
+
root: string,
|
|
80
|
+
value: string,
|
|
81
|
+
label: string,
|
|
82
|
+
): string {
|
|
62
83
|
if (!isAbsolute(value)) return requireRelativePath(value, label)
|
|
63
84
|
const pathFromRoot = relative(root, resolve(value)) || '.'
|
|
64
85
|
return requireRelativePath(pathFromRoot, label)
|
|
65
86
|
}
|
|
66
87
|
|
|
67
|
-
function requireScript(
|
|
88
|
+
function requireScript(
|
|
89
|
+
pkg: AppPackage,
|
|
90
|
+
name: 'build' | 'start' | 'worker',
|
|
91
|
+
required: boolean,
|
|
92
|
+
): boolean {
|
|
68
93
|
const value = pkg.scripts?.[name]
|
|
69
94
|
if (typeof value === 'string' && value.trim()) return true
|
|
70
|
-
if (required)
|
|
95
|
+
if (required)
|
|
96
|
+
throw new Error(
|
|
97
|
+
`[bunderstack] package.json requires a non-empty "${name}" script`,
|
|
98
|
+
)
|
|
71
99
|
return false
|
|
72
100
|
}
|
|
73
101
|
|
|
74
102
|
async function packageVersion(): Promise<string> {
|
|
75
|
-
const pkg = (await Bun.file(
|
|
103
|
+
const pkg = (await Bun.file(
|
|
104
|
+
new URL('../package.json', import.meta.url),
|
|
105
|
+
).json()) as { version: string }
|
|
76
106
|
return pkg.version
|
|
77
107
|
}
|
|
78
108
|
|
|
@@ -84,28 +114,38 @@ export async function generateBlueprint(
|
|
|
84
114
|
const pkg = JSON.parse(await readFile(packagePath, 'utf8')) as AppPackage
|
|
85
115
|
const allDependencies = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
86
116
|
if (typeof allDependencies['@tanstack/react-start'] !== 'string') {
|
|
87
|
-
throw new Error(
|
|
117
|
+
throw new Error(
|
|
118
|
+
'[bunderstack] package.json must depend on @tanstack/react-start',
|
|
119
|
+
)
|
|
88
120
|
}
|
|
89
121
|
requireScript(pkg, 'build', true)
|
|
90
122
|
requireScript(pkg, 'start', true)
|
|
91
123
|
|
|
92
124
|
const configuredEntry = pkg.bunderstack?.entry
|
|
93
125
|
const entry = requireRelativePath(
|
|
94
|
-
options.entry ??
|
|
126
|
+
options.entry ??
|
|
127
|
+
(typeof configuredEntry === 'string'
|
|
128
|
+
? configuredEntry
|
|
129
|
+
: 'src/bunderstack.ts'),
|
|
95
130
|
'entry',
|
|
96
131
|
)
|
|
97
132
|
const entryPath = resolveWithin(directory, entry, 'entry')
|
|
98
133
|
if (!(await Bun.file(entryPath).exists())) {
|
|
99
134
|
throw new Error(`[bunderstack] entry does not exist: ${entry}`)
|
|
100
135
|
}
|
|
101
|
-
const output = requireRelativePath(
|
|
136
|
+
const output = requireRelativePath(
|
|
137
|
+
options.output ?? 'bunderstack.blueprint.yaml',
|
|
138
|
+
'output',
|
|
139
|
+
)
|
|
102
140
|
const outputPath = resolveWithin(directory, output, 'output')
|
|
103
141
|
|
|
104
142
|
const previousIntrospection = process.env.BUNDERSTACK_INTROSPECT
|
|
105
143
|
process.env.BUNDERSTACK_INTROSPECT = '1'
|
|
106
144
|
let app: { manifest?: unknown; close?: () => Promise<void> } | undefined
|
|
107
145
|
try {
|
|
108
|
-
const module = (await import(
|
|
146
|
+
const module = (await import(
|
|
147
|
+
`${pathToFileURL(entryPath).href}?blueprint=${Date.now()}`
|
|
148
|
+
)) as {
|
|
109
149
|
app?: typeof app
|
|
110
150
|
}
|
|
111
151
|
app = module.app
|
|
@@ -123,7 +163,9 @@ export async function generateBlueprint(
|
|
|
123
163
|
'meta',
|
|
124
164
|
'_journal.json',
|
|
125
165
|
)
|
|
126
|
-
const migrationMode = (await Bun.file(migrationJournal).exists())
|
|
166
|
+
const migrationMode = (await Bun.file(migrationJournal).exists())
|
|
167
|
+
? 'migrations'
|
|
168
|
+
: 'push'
|
|
127
169
|
const blueprint = blueprintFromManifest({
|
|
128
170
|
manifest: {
|
|
129
171
|
...manifest,
|
|
@@ -134,12 +176,15 @@ export async function generateBlueprint(
|
|
|
134
176
|
migrationMode,
|
|
135
177
|
})
|
|
136
178
|
const source = serializeBlueprint(blueprint)
|
|
137
|
-
const existing = (await Bun.file(outputPath).exists())
|
|
179
|
+
const existing = (await Bun.file(outputPath).exists())
|
|
180
|
+
? await Bun.file(outputPath).text()
|
|
181
|
+
: undefined
|
|
138
182
|
if (options.check) {
|
|
139
183
|
if (existing !== source) throw new BlueprintCheckError()
|
|
140
184
|
return { path: outputPath, blueprint, source, changed: false }
|
|
141
185
|
}
|
|
142
|
-
if (existing === source)
|
|
186
|
+
if (existing === source)
|
|
187
|
+
return { path: outputPath, blueprint, source, changed: false }
|
|
143
188
|
await mkdir(dirname(outputPath), { recursive: true })
|
|
144
189
|
const temporary = `${outputPath}.${process.pid}.${Date.now()}.tmp`
|
|
145
190
|
try {
|
|
@@ -151,7 +196,8 @@ export async function generateBlueprint(
|
|
|
151
196
|
return { path: outputPath, blueprint, source, changed: true }
|
|
152
197
|
} finally {
|
|
153
198
|
await app?.close?.()
|
|
154
|
-
if (previousIntrospection === undefined)
|
|
199
|
+
if (previousIntrospection === undefined)
|
|
200
|
+
delete process.env.BUNDERSTACK_INTROSPECT
|
|
155
201
|
else process.env.BUNDERSTACK_INTROSPECT = previousIntrospection
|
|
156
202
|
}
|
|
157
203
|
}
|
package/src/blueprint.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { parse, stringify } from 'yaml'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
|
|
4
|
-
import { parseCron } from './jobs/cron'
|
|
5
4
|
import type { BunderstackManifest } from './manifest'
|
|
6
5
|
|
|
6
|
+
import { parseCron } from './jobs/cron'
|
|
7
|
+
|
|
7
8
|
export type MigrationMode = 'migrations' | 'push'
|
|
8
9
|
|
|
9
10
|
export type BunderstackBlueprint = {
|
|
@@ -48,12 +49,18 @@ const cronSchedule = nonEmpty.refine(
|
|
|
48
49
|
const blueprintSchema = z
|
|
49
50
|
.object({
|
|
50
51
|
version: z.literal(1),
|
|
51
|
-
generator: z
|
|
52
|
+
generator: z
|
|
53
|
+
.object({ name: z.literal('bunderstack'), version: nonEmpty })
|
|
54
|
+
.strict(),
|
|
52
55
|
application: z
|
|
53
56
|
.object({
|
|
54
57
|
framework: z.literal('tanstack-start'),
|
|
55
58
|
scripts: z
|
|
56
|
-
.object({
|
|
59
|
+
.object({
|
|
60
|
+
build: z.literal('build'),
|
|
61
|
+
start: z.literal('start'),
|
|
62
|
+
worker: z.literal('worker').optional(),
|
|
63
|
+
})
|
|
57
64
|
.strict(),
|
|
58
65
|
})
|
|
59
66
|
.strict(),
|
|
@@ -68,29 +75,65 @@ const blueprintSchema = z
|
|
|
68
75
|
migrationsDirectory: relativePath,
|
|
69
76
|
migrationMode: z.enum(['migrations', 'push']),
|
|
70
77
|
tables: z.array(
|
|
71
|
-
z
|
|
78
|
+
z
|
|
79
|
+
.object({
|
|
80
|
+
exportName: nonEmpty,
|
|
81
|
+
physicalName: nonEmpty,
|
|
82
|
+
system: z.boolean(),
|
|
83
|
+
})
|
|
84
|
+
.strict(),
|
|
72
85
|
),
|
|
73
86
|
})
|
|
74
87
|
.strict(),
|
|
75
88
|
storage: z
|
|
76
89
|
.object({
|
|
77
90
|
defaultBucket: nonEmpty,
|
|
78
|
-
buckets: z.array(
|
|
91
|
+
buckets: z.array(
|
|
92
|
+
z
|
|
93
|
+
.object({
|
|
94
|
+
name: nonEmpty,
|
|
95
|
+
visibility: z.enum(['public', 'private']),
|
|
96
|
+
})
|
|
97
|
+
.strict(),
|
|
98
|
+
),
|
|
79
99
|
})
|
|
80
100
|
.strict(),
|
|
81
|
-
realtime: z
|
|
101
|
+
realtime: z
|
|
102
|
+
.object({ required: z.literal(true) })
|
|
103
|
+
.strict()
|
|
104
|
+
.optional(),
|
|
82
105
|
})
|
|
83
106
|
.strict(),
|
|
84
107
|
environment: z.array(
|
|
85
|
-
z
|
|
108
|
+
z
|
|
109
|
+
.object({
|
|
110
|
+
key: nonEmpty,
|
|
111
|
+
required: z.boolean(),
|
|
112
|
+
scope: z.enum(['server', 'client']),
|
|
113
|
+
})
|
|
114
|
+
.strict(),
|
|
86
115
|
),
|
|
87
116
|
background: z
|
|
88
117
|
.object({
|
|
89
118
|
worker: z.object({ required: z.boolean() }).strict(),
|
|
90
119
|
jobs: z.array(z.object({ name: nonEmpty }).strict()),
|
|
91
|
-
cron: z.array(
|
|
120
|
+
cron: z.array(
|
|
121
|
+
z
|
|
122
|
+
.object({
|
|
123
|
+
name: nonEmpty,
|
|
124
|
+
schedule: cronSchedule,
|
|
125
|
+
timezone: z.literal('UTC'),
|
|
126
|
+
})
|
|
127
|
+
.strict(),
|
|
128
|
+
),
|
|
92
129
|
maintenance: z.array(
|
|
93
|
-
z
|
|
130
|
+
z
|
|
131
|
+
.object({
|
|
132
|
+
name: z.literal('storage-sweep'),
|
|
133
|
+
schedule: cronSchedule,
|
|
134
|
+
timezone: z.literal('UTC'),
|
|
135
|
+
})
|
|
136
|
+
.strict(),
|
|
94
137
|
),
|
|
95
138
|
})
|
|
96
139
|
.strict(),
|
|
@@ -104,29 +147,61 @@ function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
|
|
|
104
147
|
function rejectDuplicates(collection: string, values: readonly string[]): void {
|
|
105
148
|
const seen = new Set<string>()
|
|
106
149
|
for (const value of values) {
|
|
107
|
-
if (seen.has(value))
|
|
150
|
+
if (seen.has(value))
|
|
151
|
+
throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
|
|
108
152
|
seen.add(value)
|
|
109
153
|
}
|
|
110
154
|
}
|
|
111
155
|
|
|
112
156
|
export function parseBlueprint(value: unknown): BunderstackBlueprint {
|
|
113
157
|
const blueprint = blueprintSchema.parse(value) as BunderstackBlueprint
|
|
114
|
-
rejectDuplicates(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
rejectDuplicates(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
158
|
+
rejectDuplicates(
|
|
159
|
+
'database physical table',
|
|
160
|
+
blueprint.resources.database.tables.map((entry) => entry.physicalName),
|
|
161
|
+
)
|
|
162
|
+
rejectDuplicates(
|
|
163
|
+
'database export table',
|
|
164
|
+
blueprint.resources.database.tables.map((entry) => entry.exportName),
|
|
165
|
+
)
|
|
166
|
+
rejectDuplicates(
|
|
167
|
+
'storage bucket',
|
|
168
|
+
blueprint.resources.storage.buckets.map((entry) => entry.name),
|
|
169
|
+
)
|
|
170
|
+
rejectDuplicates(
|
|
171
|
+
'environment key',
|
|
172
|
+
blueprint.environment.map((entry) => entry.key),
|
|
173
|
+
)
|
|
174
|
+
rejectDuplicates(
|
|
175
|
+
'background job',
|
|
176
|
+
blueprint.background.jobs.map((entry) => entry.name),
|
|
177
|
+
)
|
|
178
|
+
rejectDuplicates(
|
|
179
|
+
'background cron',
|
|
180
|
+
blueprint.background.cron.map((entry) => entry.name),
|
|
181
|
+
)
|
|
182
|
+
rejectDuplicates(
|
|
183
|
+
'background maintenance',
|
|
184
|
+
blueprint.background.maintenance.map((entry) => entry.name),
|
|
185
|
+
)
|
|
186
|
+
if (
|
|
187
|
+
!blueprint.resources.storage.buckets.some(
|
|
188
|
+
(bucket) => bucket.name === blueprint.resources.storage.defaultBucket,
|
|
189
|
+
)
|
|
190
|
+
) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
'[bunderstack] storage defaultBucket must be declared in storage buckets',
|
|
193
|
+
)
|
|
123
194
|
}
|
|
124
195
|
const workerRequired = blueprint.background.jobs.length > 0
|
|
125
196
|
if (blueprint.background.worker.required !== workerRequired) {
|
|
126
|
-
throw new Error(
|
|
197
|
+
throw new Error(
|
|
198
|
+
'[bunderstack] background worker.required must match declared queue jobs',
|
|
199
|
+
)
|
|
127
200
|
}
|
|
128
201
|
if (Boolean(blueprint.application.scripts.worker) !== workerRequired) {
|
|
129
|
-
throw new Error(
|
|
202
|
+
throw new Error(
|
|
203
|
+
'[bunderstack] application worker script must match declared queue jobs',
|
|
204
|
+
)
|
|
130
205
|
}
|
|
131
206
|
return blueprint
|
|
132
207
|
}
|
|
@@ -143,27 +218,39 @@ export function blueprintFromManifest(args: {
|
|
|
143
218
|
generator: { name: 'bunderstack', version: args.generatorVersion },
|
|
144
219
|
application: {
|
|
145
220
|
framework: 'tanstack-start',
|
|
146
|
-
scripts: {
|
|
221
|
+
scripts: {
|
|
222
|
+
build: 'build',
|
|
223
|
+
start: 'start',
|
|
224
|
+
...(workerRequired ? { worker: 'worker' } : {}),
|
|
225
|
+
},
|
|
147
226
|
},
|
|
148
227
|
bunderstack: { entry: args.entry, manifestVersion: 3 },
|
|
149
228
|
resources: {
|
|
150
229
|
database: {
|
|
151
230
|
...args.manifest.database,
|
|
152
231
|
migrationMode: args.migrationMode,
|
|
153
|
-
tables: sortBy(
|
|
232
|
+
tables: sortBy(
|
|
233
|
+
args.manifest.database.tables,
|
|
234
|
+
(entry) => entry.physicalName,
|
|
235
|
+
),
|
|
154
236
|
},
|
|
155
237
|
storage: {
|
|
156
238
|
...args.manifest.storage,
|
|
157
239
|
buckets: sortBy(args.manifest.storage.buckets, (entry) => entry.name),
|
|
158
240
|
},
|
|
159
|
-
...(args.manifest.realtime.required
|
|
241
|
+
...(args.manifest.realtime.required
|
|
242
|
+
? { realtime: { required: true } }
|
|
243
|
+
: {}),
|
|
160
244
|
},
|
|
161
245
|
environment: sortBy(args.manifest.environment, (entry) => entry.key),
|
|
162
246
|
background: {
|
|
163
247
|
worker: { required: workerRequired },
|
|
164
248
|
jobs: sortBy(args.manifest.background.jobs, (entry) => entry.name),
|
|
165
249
|
cron: sortBy(args.manifest.background.cron, (entry) => entry.name),
|
|
166
|
-
maintenance: sortBy(
|
|
250
|
+
maintenance: sortBy(
|
|
251
|
+
args.manifest.background.maintenance,
|
|
252
|
+
(entry) => entry.name,
|
|
253
|
+
),
|
|
167
254
|
},
|
|
168
255
|
})
|
|
169
256
|
}
|
|
@@ -180,9 +267,15 @@ export function serializeBlueprint(value: BunderstackBlueprint): string {
|
|
|
180
267
|
defaultStringType: 'PLAIN',
|
|
181
268
|
lineWidth: 0,
|
|
182
269
|
} as const
|
|
183
|
-
const source = stringify(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
270
|
+
const source = stringify(
|
|
271
|
+
parseBlueprintYaml(stringify(blueprint, options)),
|
|
272
|
+
options,
|
|
273
|
+
)
|
|
274
|
+
return source.replace(
|
|
275
|
+
/^(\s*schedule: )(.+)$/gm,
|
|
276
|
+
(_match, prefix: string, value: string) => {
|
|
277
|
+
const schedule = value.startsWith('"') ? JSON.parse(value) : value
|
|
278
|
+
return `${prefix}${JSON.stringify(schedule)}`
|
|
279
|
+
},
|
|
280
|
+
)
|
|
188
281
|
}
|
package/src/cli.ts
CHANGED
|
@@ -25,11 +25,19 @@ export async function runCli(
|
|
|
25
25
|
return 0
|
|
26
26
|
}
|
|
27
27
|
if (args[0] === '--version') {
|
|
28
|
-
io.stdout(
|
|
28
|
+
io.stdout(
|
|
29
|
+
(
|
|
30
|
+
(await Bun.file(
|
|
31
|
+
new URL('../package.json', import.meta.url),
|
|
32
|
+
).json()) as { version: string }
|
|
33
|
+
).version,
|
|
34
|
+
)
|
|
29
35
|
return 0
|
|
30
36
|
}
|
|
31
37
|
if (args[0] !== 'blueprint') {
|
|
32
|
-
io.stderr(
|
|
38
|
+
io.stderr(
|
|
39
|
+
'Usage: bunderstack blueprint [directory] [--entry <path>] [--output <path>] [--check]',
|
|
40
|
+
)
|
|
33
41
|
return 2
|
|
34
42
|
}
|
|
35
43
|
const options: GenerateBlueprintOptions = { directory: process.cwd() }
|
package/src/crud.ts
CHANGED
|
@@ -163,7 +163,11 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
163
163
|
)
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
const scope = scopeFor(tableAccess.readScope, {
|
|
166
|
+
const scope = scopeFor(tableAccess.readScope, {
|
|
167
|
+
user,
|
|
168
|
+
session,
|
|
169
|
+
request: c.req.raw,
|
|
170
|
+
})
|
|
167
171
|
if (
|
|
168
172
|
scope &&
|
|
169
173
|
!rowMatchesScope(rows[0] as Record<string, unknown>, scope)
|
|
@@ -240,7 +244,12 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
240
244
|
user?.id ?? null,
|
|
241
245
|
)
|
|
242
246
|
|
|
243
|
-
const scope = scopeFor(tableAccess.writeScope, {
|
|
247
|
+
const scope = scopeFor(tableAccess.writeScope, {
|
|
248
|
+
user,
|
|
249
|
+
session,
|
|
250
|
+
request: c.req.raw,
|
|
251
|
+
body: body as Record<string, unknown>,
|
|
252
|
+
})
|
|
244
253
|
const stamped = scope ? stampScope(values, scope) : values
|
|
245
254
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
246
255
|
const rows = await (db as any).insert(table).values(stamped).returning()
|
|
@@ -279,7 +288,11 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
279
288
|
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
280
289
|
}
|
|
281
290
|
|
|
282
|
-
const readScope = scopeFor(tableAccess.readScope, {
|
|
291
|
+
const readScope = scopeFor(tableAccess.readScope, {
|
|
292
|
+
user,
|
|
293
|
+
session,
|
|
294
|
+
request: c.req.raw,
|
|
295
|
+
})
|
|
283
296
|
if (
|
|
284
297
|
readScope &&
|
|
285
298
|
!rowMatchesScope(existing[0] as Record<string, unknown>, readScope)
|
|
@@ -319,7 +332,12 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
319
332
|
user?.id ?? null,
|
|
320
333
|
)
|
|
321
334
|
|
|
322
|
-
const writeScope = scopeFor(tableAccess.writeScope, {
|
|
335
|
+
const writeScope = scopeFor(tableAccess.writeScope, {
|
|
336
|
+
user,
|
|
337
|
+
session,
|
|
338
|
+
request: c.req.raw,
|
|
339
|
+
body: body as Record<string, unknown>,
|
|
340
|
+
})
|
|
323
341
|
const stamped = writeScope ? stampScope(values, writeScope) : values
|
|
324
342
|
|
|
325
343
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -352,7 +370,11 @@ export function buildCrudRouter<TSchema extends Record<string, unknown>>(
|
|
|
352
370
|
return apiError(c, ErrorCode.NOT_FOUND, 'Not found', 404)
|
|
353
371
|
}
|
|
354
372
|
|
|
355
|
-
const scope = scopeFor(tableAccess.readScope, {
|
|
373
|
+
const scope = scopeFor(tableAccess.readScope, {
|
|
374
|
+
user,
|
|
375
|
+
session,
|
|
376
|
+
request: c.req.raw,
|
|
377
|
+
})
|
|
356
378
|
if (
|
|
357
379
|
scope &&
|
|
358
380
|
!rowMatchesScope(existing[0] as Record<string, unknown>, scope)
|
package/src/env.ts
CHANGED
|
@@ -22,9 +22,10 @@ export type BaseEnv = {
|
|
|
22
22
|
BUNDERSTACK_CRON_SECRET?: string
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
type InferVars<T> =
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
type InferVars<T> =
|
|
26
|
+
T extends Record<string, ZodType>
|
|
27
|
+
? { [K in keyof T]: z.output<T[K]> }
|
|
28
|
+
: unknown
|
|
28
29
|
|
|
29
30
|
// Non-distributive so `ValidatedEnv<undefined>` is BaseEnv, not `never`.
|
|
30
31
|
export type ValidatedEnv<TEnv extends EnvConfigInput | undefined> = [
|
|
@@ -113,8 +114,14 @@ export function validateEnv<TEnv extends EnvConfigInput | undefined>(
|
|
|
113
114
|
if (isProduction && !source.AUTH_SECRET) {
|
|
114
115
|
issues.push('AUTH_SECRET: required in production')
|
|
115
116
|
}
|
|
116
|
-
if (
|
|
117
|
-
|
|
117
|
+
if (
|
|
118
|
+
isProduction &&
|
|
119
|
+
options.cronConfigured &&
|
|
120
|
+
!source.BUNDERSTACK_CRON_SECRET
|
|
121
|
+
) {
|
|
122
|
+
issues.push(
|
|
123
|
+
'BUNDERSTACK_CRON_SECRET: required when cron is configured in production',
|
|
124
|
+
)
|
|
118
125
|
}
|
|
119
126
|
if (options.emailProvider === 'resend' && !source.RESEND_API_KEY) {
|
|
120
127
|
issues.push("RESEND_API_KEY: required when email provider is 'resend'")
|
package/src/internal-tables.ts
CHANGED
|
@@ -121,7 +121,10 @@ const INTERNAL_TABLE_CANDIDATES = new Map<string, readonly unknown[]>([
|
|
|
121
121
|
[bunderstackIdempotency, bunderstackIdempotencyPg],
|
|
122
122
|
],
|
|
123
123
|
[getTableName(bunderstackJobs), [bunderstackJobs, bunderstackJobsPg]],
|
|
124
|
-
[
|
|
124
|
+
[
|
|
125
|
+
getTableName(bunderstackCronRuns),
|
|
126
|
+
[bunderstackCronRuns, bunderstackCronRunsPg],
|
|
127
|
+
],
|
|
125
128
|
])
|
|
126
129
|
|
|
127
130
|
/** Internal file-meta table matching the db's dialect. */
|
package/src/jobs/cron-router.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { Hono } from 'hono'
|
|
2
1
|
import { and, eq, lt } from 'drizzle-orm'
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
3
|
|
|
4
4
|
import type { AnyDb } from '../dialect'
|
|
5
5
|
import type { BackgroundDefs } from './define'
|
|
6
6
|
|
|
7
|
+
import { cronRunsTableFor } from '../internal-tables'
|
|
7
8
|
import { verifyScheduleRequest } from './cron-auth'
|
|
8
9
|
import { runCronSlot, runScheduledSlot } from './cron-runner'
|
|
9
|
-
import { cronRunsTableFor } from '../internal-tables'
|
|
10
10
|
|
|
11
11
|
const MAX_SLOT_AGE_MS = 60 * 60_000
|
|
12
12
|
const MAX_FUTURE_SLOT_MS = 60_000
|
|
@@ -56,10 +56,7 @@ export function buildCronRouter(args: {
|
|
|
56
56
|
slot,
|
|
57
57
|
now: current,
|
|
58
58
|
})
|
|
59
|
-
return c.json(
|
|
60
|
-
result,
|
|
61
|
-
result.status === 'running' ? 202 : 200,
|
|
62
|
-
)
|
|
59
|
+
return c.json(result, result.status === 'running' ? 202 : 200)
|
|
63
60
|
} catch (error) {
|
|
64
61
|
if (
|
|
65
62
|
error instanceof Error &&
|
|
@@ -79,7 +76,14 @@ export function buildCronRouter(args: {
|
|
|
79
76
|
if (!slotText || !Number.isSafeInteger(slot) || !signature) {
|
|
80
77
|
return c.json({ error: 'invalid schedule signature' }, 401)
|
|
81
78
|
}
|
|
82
|
-
if (
|
|
79
|
+
if (
|
|
80
|
+
!verifyScheduleRequest(
|
|
81
|
+
args.secret,
|
|
82
|
+
`maintenance:${name}`,
|
|
83
|
+
slot,
|
|
84
|
+
signature,
|
|
85
|
+
)
|
|
86
|
+
) {
|
|
83
87
|
return c.json({ error: 'invalid schedule signature' }, 401)
|
|
84
88
|
}
|
|
85
89
|
if (name !== 'storage-sweep') {
|
package/src/jobs/cron-runner.ts
CHANGED
|
@@ -22,11 +22,13 @@ export async function runScheduledSlot(args: {
|
|
|
22
22
|
run: (scheduledFor: Date) => Promise<void> | void
|
|
23
23
|
leaseMs?: number
|
|
24
24
|
heartbeatIntervalMs?: number
|
|
25
|
+
heartbeatCleanupTimeoutMs?: number
|
|
25
26
|
}): Promise<CronRunResult> {
|
|
26
27
|
const { db, taskId, schedule, slot, now, run } = args
|
|
27
28
|
const leaseMs = args.leaseMs ?? LEASE_MS
|
|
28
29
|
const heartbeatIntervalMs =
|
|
29
30
|
args.heartbeatIntervalMs ?? Math.max(1, Math.floor(leaseMs / 4))
|
|
31
|
+
const heartbeatCleanupTimeoutMs = args.heartbeatCleanupTimeoutMs ?? 1_000
|
|
30
32
|
|
|
31
33
|
if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
|
|
32
34
|
throw new Error('[bunderstack] cron slot does not match its schedule')
|
|
@@ -45,9 +47,12 @@ export async function runScheduledSlot(args: {
|
|
|
45
47
|
startedAt: now,
|
|
46
48
|
})
|
|
47
49
|
.onConflictDoNothing({ target: [t.taskId, t.scheduledAt] })
|
|
48
|
-
.returning({ taskId: t.taskId })
|
|
50
|
+
.returning({ taskId: t.taskId, attempts: t.attempts })
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
let ownershipAttempt: number
|
|
53
|
+
if (inserted[0]) {
|
|
54
|
+
ownershipAttempt = Number(inserted[0].attempts)
|
|
55
|
+
} else {
|
|
51
56
|
const existing = await db
|
|
52
57
|
.select({ status: t.status, lockedUntil: t.lockedUntil })
|
|
53
58
|
.from(t)
|
|
@@ -74,42 +79,75 @@ export async function runScheduledSlot(args: {
|
|
|
74
79
|
or(eq(t.status, 'failed'), lt(t.lockedUntil, now)),
|
|
75
80
|
),
|
|
76
81
|
)
|
|
77
|
-
.returning({ taskId: t.taskId })
|
|
78
|
-
|
|
82
|
+
.returning({ taskId: t.taskId, attempts: t.attempts })
|
|
83
|
+
const reclaimedRow = reclaimed[0]
|
|
84
|
+
if (!reclaimedRow) return { status: 'running' }
|
|
85
|
+
ownershipAttempt = Number(reclaimedRow.attempts)
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
let heartbeatTimer: Timer | undefined
|
|
82
|
-
|
|
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
|
|
83
126
|
if (heartbeatTimer) {
|
|
84
|
-
|
|
127
|
+
clearTimeout(heartbeatTimer)
|
|
85
128
|
heartbeatTimer = undefined
|
|
86
129
|
}
|
|
87
|
-
|
|
130
|
+
const inFlight = heartbeatInFlight
|
|
131
|
+
if (!inFlight) return
|
|
88
132
|
|
|
89
|
-
|
|
133
|
+
let cleanupTimer: Timer | undefined
|
|
90
134
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
eq(t.status, 'running'),
|
|
100
|
-
eq(t.startedAt, now),
|
|
101
|
-
),
|
|
102
|
-
)
|
|
103
|
-
} catch {
|
|
104
|
-
// Best effort renewal
|
|
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)
|
|
105
143
|
}
|
|
106
|
-
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
scheduleHeartbeat()
|
|
107
147
|
|
|
108
148
|
try {
|
|
109
149
|
await run(new Date(slot))
|
|
110
150
|
|
|
111
|
-
stopHeartbeat()
|
|
112
|
-
|
|
113
151
|
const updated = await db
|
|
114
152
|
.update(t)
|
|
115
153
|
.set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
|
|
@@ -119,6 +157,7 @@ export async function runScheduledSlot(args: {
|
|
|
119
157
|
eq(t.scheduledAt, slot),
|
|
120
158
|
eq(t.status, 'running'),
|
|
121
159
|
eq(t.startedAt, now),
|
|
160
|
+
eq(t.attempts, ownershipAttempt),
|
|
122
161
|
),
|
|
123
162
|
)
|
|
124
163
|
.returning({ taskId: t.taskId })
|
|
@@ -131,8 +170,6 @@ export async function runScheduledSlot(args: {
|
|
|
131
170
|
|
|
132
171
|
return { status: 'succeeded' }
|
|
133
172
|
} catch (error) {
|
|
134
|
-
stopHeartbeat()
|
|
135
|
-
|
|
136
173
|
const message = error instanceof Error ? error.message : String(error)
|
|
137
174
|
await db
|
|
138
175
|
.update(t)
|
|
@@ -148,11 +185,12 @@ export async function runScheduledSlot(args: {
|
|
|
148
185
|
eq(t.scheduledAt, slot),
|
|
149
186
|
eq(t.status, 'running'),
|
|
150
187
|
eq(t.startedAt, now),
|
|
188
|
+
eq(t.attempts, ownershipAttempt),
|
|
151
189
|
),
|
|
152
190
|
)
|
|
153
191
|
throw error
|
|
154
192
|
} finally {
|
|
155
|
-
stopHeartbeat()
|
|
193
|
+
await stopHeartbeat()
|
|
156
194
|
}
|
|
157
195
|
}
|
|
158
196
|
|
|
@@ -165,6 +203,7 @@ export async function runCronSlot(args: {
|
|
|
165
203
|
now: number
|
|
166
204
|
leaseMs?: number
|
|
167
205
|
heartbeatIntervalMs?: number
|
|
206
|
+
heartbeatCleanupTimeoutMs?: number
|
|
168
207
|
}): Promise<CronRunResult> {
|
|
169
208
|
const definition = args.defs[args.name]
|
|
170
209
|
if (!definition || definition.kind !== 'cron') {
|
|
@@ -178,6 +217,7 @@ export async function runCronSlot(args: {
|
|
|
178
217
|
now: args.now,
|
|
179
218
|
leaseMs: args.leaseMs,
|
|
180
219
|
heartbeatIntervalMs: args.heartbeatIntervalMs,
|
|
220
|
+
heartbeatCleanupTimeoutMs: args.heartbeatCleanupTimeoutMs,
|
|
181
221
|
run: (scheduledFor) =>
|
|
182
222
|
definition.handler({ scheduledFor }, args.ctx as never),
|
|
183
223
|
})
|
package/src/jobs/index.ts
CHANGED
|
@@ -30,7 +30,11 @@ export type { CronRunResult } from './cron-runner'
|
|
|
30
30
|
export { buildCronRouter } from './cron-router'
|
|
31
31
|
export { signScheduleRequest, verifyScheduleRequest } from './cron-auth'
|
|
32
32
|
export { startJobWorker } from './runtime'
|
|
33
|
-
export type {
|
|
33
|
+
export type {
|
|
34
|
+
StartWorkerOptions,
|
|
35
|
+
RunWorkerOptions,
|
|
36
|
+
WorkerHandle,
|
|
37
|
+
} from './runtime'
|
|
34
38
|
export { startLocalCronScheduler } from './local-cron'
|
|
35
39
|
export type {
|
|
36
40
|
LocalCronScheduler,
|
package/src/jobs/worker.ts
CHANGED
|
@@ -4,11 +4,7 @@ import { and, eq, inArray, is, isNotNull, lt, lte, 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 {
|
|
8
|
-
AnyJobDefinition,
|
|
9
|
-
JobsDefs,
|
|
10
|
-
JobsRuntimeFacade,
|
|
11
|
-
} from './define'
|
|
7
|
+
import type { AnyJobDefinition, JobsDefs, JobsRuntimeFacade } from './define'
|
|
12
8
|
|
|
13
9
|
import { jobsTableFor } from '../internal-tables'
|
|
14
10
|
import { backoffMs, DEFAULT_RETRIES, DEFAULT_TIMEOUT_MS } from './define'
|
|
@@ -77,7 +73,11 @@ export function createJobRunner(deps: {
|
|
|
77
73
|
})
|
|
78
74
|
.from(t)
|
|
79
75
|
.where(
|
|
80
|
-
and(
|
|
76
|
+
and(
|
|
77
|
+
eq(t.status, 'running'),
|
|
78
|
+
isNotNull(t.lockedUntil),
|
|
79
|
+
lt(t.lockedUntil, now),
|
|
80
|
+
),
|
|
81
81
|
)
|
|
82
82
|
for (const row of expired) {
|
|
83
83
|
const def = defs[row.type]
|
|
@@ -148,8 +148,11 @@ export function createJobRunner(deps: {
|
|
|
148
148
|
// PG: lock the selected rows so concurrent replicas skip them. SQLite's
|
|
149
149
|
// single-writer model makes the one-statement UPDATE atomic on its own.
|
|
150
150
|
const sub = is(db, PgDatabase)
|
|
151
|
-
? (
|
|
152
|
-
|
|
151
|
+
? (
|
|
152
|
+
pendingIds as unknown as {
|
|
153
|
+
for: (m: string, o: object) => typeof pendingIds
|
|
154
|
+
}
|
|
155
|
+
).for('update', { skipLocked: true })
|
|
153
156
|
: pendingIds
|
|
154
157
|
const rows: JobRow[] = await db
|
|
155
158
|
.update(t)
|
package/src/list-query.ts
CHANGED
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
} from 'drizzle-orm'
|
|
17
17
|
import { PgTable } from 'drizzle-orm/pg-core'
|
|
18
18
|
|
|
19
|
-
import type { AnyDb } from './dialect'
|
|
20
19
|
import type { ResolvedTableAccess, SortOrder } from './access'
|
|
20
|
+
import type { AnyDb } from './dialect'
|
|
21
21
|
|
|
22
22
|
import { ErrorCode, ListQueryError } from './errors'
|
|
23
23
|
|
|
@@ -282,9 +282,7 @@ export function encodeCursor(payload: CursorPayload): string {
|
|
|
282
282
|
|
|
283
283
|
export function decodeCursor(cursor: string): CursorPayload {
|
|
284
284
|
try {
|
|
285
|
-
const parsed = JSON.parse(
|
|
286
|
-
Buffer.from(cursor, 'base64url').toString('utf8'),
|
|
287
|
-
)
|
|
285
|
+
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
|
|
288
286
|
if (!isCursorPayload(parsed)) {
|
|
289
287
|
throw new Error('invalid cursor shape')
|
|
290
288
|
}
|
package/src/manifest.ts
CHANGED
|
@@ -5,13 +5,14 @@ import type { Dialect } from './dialect'
|
|
|
5
5
|
import type { EnvConfigInput } from './env'
|
|
6
6
|
import type { JobsDefs } from './jobs/define'
|
|
7
7
|
import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
import {
|
|
10
10
|
bunderstackCronRuns,
|
|
11
11
|
bunderstackFiles,
|
|
12
12
|
bunderstackIdempotency,
|
|
13
13
|
bunderstackJobs,
|
|
14
14
|
} from './internal-tables'
|
|
15
|
+
import { parseCron } from './jobs/cron'
|
|
15
16
|
|
|
16
17
|
export type ManifestEnvVar = {
|
|
17
18
|
key: string
|
|
@@ -47,8 +48,12 @@ const nonEmpty = z.string().min(1)
|
|
|
47
48
|
const migrationDirectory = nonEmpty.refine(
|
|
48
49
|
(value) =>
|
|
49
50
|
value.startsWith('/') ||
|
|
50
|
-
(!value.includes('\\') &&
|
|
51
|
-
|
|
51
|
+
(!value.includes('\\') &&
|
|
52
|
+
value.split('/').every((part) => part !== '..' && part !== '')),
|
|
53
|
+
{
|
|
54
|
+
message:
|
|
55
|
+
'migrationsDirectory must be an absolute path or a relative path without traversal',
|
|
56
|
+
},
|
|
52
57
|
)
|
|
53
58
|
const cronSchedule = nonEmpty.refine(
|
|
54
59
|
(value) => {
|
|
@@ -85,7 +90,10 @@ const manifestSchema = z
|
|
|
85
90
|
defaultBucket: nonEmpty,
|
|
86
91
|
buckets: z.array(
|
|
87
92
|
z
|
|
88
|
-
.object({
|
|
93
|
+
.object({
|
|
94
|
+
name: nonEmpty,
|
|
95
|
+
visibility: z.enum(['public', 'private']),
|
|
96
|
+
})
|
|
89
97
|
.strict(),
|
|
90
98
|
),
|
|
91
99
|
})
|
|
@@ -133,13 +141,16 @@ function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
|
|
|
133
141
|
function rejectDuplicates(collection: string, values: readonly string[]): void {
|
|
134
142
|
const seen = new Set<string>()
|
|
135
143
|
for (const value of values) {
|
|
136
|
-
if (seen.has(value))
|
|
144
|
+
if (seen.has(value))
|
|
145
|
+
throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
|
|
137
146
|
seen.add(value)
|
|
138
147
|
}
|
|
139
148
|
}
|
|
140
149
|
|
|
141
150
|
function describeTables(schema: Record<string, unknown>) {
|
|
142
|
-
const systemNames = new Set<string>(
|
|
151
|
+
const systemNames = new Set<string>(
|
|
152
|
+
systemTables().map((table) => table.physicalName),
|
|
153
|
+
)
|
|
143
154
|
return sortBy(
|
|
144
155
|
Object.entries(schema).flatMap(([exportName, value]) =>
|
|
145
156
|
isTable(value) && !systemNames.has(getTableName(value))
|
|
@@ -163,13 +174,21 @@ function describeSection(
|
|
|
163
174
|
|
|
164
175
|
function systemTables() {
|
|
165
176
|
return [
|
|
166
|
-
{
|
|
177
|
+
{
|
|
178
|
+
exportName: '_system.files',
|
|
179
|
+
physicalName: getTableName(bunderstackFiles),
|
|
180
|
+
system: true,
|
|
181
|
+
},
|
|
167
182
|
{
|
|
168
183
|
exportName: '_system.idempotency',
|
|
169
184
|
physicalName: getTableName(bunderstackIdempotency),
|
|
170
185
|
system: true,
|
|
171
186
|
},
|
|
172
|
-
{
|
|
187
|
+
{
|
|
188
|
+
exportName: '_system.jobs',
|
|
189
|
+
physicalName: getTableName(bunderstackJobs),
|
|
190
|
+
system: true,
|
|
191
|
+
},
|
|
173
192
|
{
|
|
174
193
|
exportName: '_system.scheduledRuns',
|
|
175
194
|
physicalName: getTableName(bunderstackCronRuns),
|
|
@@ -180,13 +199,34 @@ function systemTables() {
|
|
|
180
199
|
|
|
181
200
|
export function parseManifest(value: unknown): BunderstackManifest {
|
|
182
201
|
const manifest = manifestSchema.parse(value) as BunderstackManifest
|
|
183
|
-
rejectDuplicates(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
rejectDuplicates(
|
|
188
|
-
|
|
189
|
-
|
|
202
|
+
rejectDuplicates(
|
|
203
|
+
'database physical table',
|
|
204
|
+
manifest.database.tables.map((entry) => entry.physicalName),
|
|
205
|
+
)
|
|
206
|
+
rejectDuplicates(
|
|
207
|
+
'database export table',
|
|
208
|
+
manifest.database.tables.map((entry) => entry.exportName),
|
|
209
|
+
)
|
|
210
|
+
rejectDuplicates(
|
|
211
|
+
'storage bucket',
|
|
212
|
+
manifest.storage.buckets.map((entry) => entry.name),
|
|
213
|
+
)
|
|
214
|
+
rejectDuplicates(
|
|
215
|
+
'environment key',
|
|
216
|
+
manifest.environment.map((entry) => entry.key),
|
|
217
|
+
)
|
|
218
|
+
rejectDuplicates(
|
|
219
|
+
'background job',
|
|
220
|
+
manifest.background.jobs.map((entry) => entry.name),
|
|
221
|
+
)
|
|
222
|
+
rejectDuplicates(
|
|
223
|
+
'background cron',
|
|
224
|
+
manifest.background.cron.map((entry) => entry.name),
|
|
225
|
+
)
|
|
226
|
+
rejectDuplicates(
|
|
227
|
+
'background maintenance',
|
|
228
|
+
manifest.background.maintenance.map((entry) => entry.name),
|
|
229
|
+
)
|
|
190
230
|
return manifest
|
|
191
231
|
}
|
|
192
232
|
|
|
@@ -210,14 +250,20 @@ export function buildManifest(args: {
|
|
|
210
250
|
? [{ key: 'SMTP_URL', required: true, scope: 'server' as const }]
|
|
211
251
|
: []),
|
|
212
252
|
]
|
|
213
|
-
rejectDuplicates(
|
|
253
|
+
rejectDuplicates(
|
|
254
|
+
'environment key',
|
|
255
|
+
environment.map((entry) => entry.key),
|
|
256
|
+
)
|
|
214
257
|
|
|
215
258
|
return parseManifest({
|
|
216
259
|
version: 3,
|
|
217
260
|
database: {
|
|
218
261
|
dialect: args.dialect,
|
|
219
262
|
migrationsDirectory: args.migrationsDirectory,
|
|
220
|
-
tables: sortBy(
|
|
263
|
+
tables: sortBy(
|
|
264
|
+
[...systemTables(), ...describeTables(args.schema)],
|
|
265
|
+
(entry) => entry.physicalName,
|
|
266
|
+
),
|
|
221
267
|
},
|
|
222
268
|
storage: {
|
|
223
269
|
defaultBucket: args.storage.defaultBucket,
|
|
@@ -249,7 +295,11 @@ export function buildManifest(args: {
|
|
|
249
295
|
(entry) => entry.name,
|
|
250
296
|
),
|
|
251
297
|
maintenance: [
|
|
252
|
-
{
|
|
298
|
+
{
|
|
299
|
+
name: 'storage-sweep',
|
|
300
|
+
schedule: '0 4 * * *',
|
|
301
|
+
timezone: 'UTC' as const,
|
|
302
|
+
},
|
|
253
303
|
],
|
|
254
304
|
},
|
|
255
305
|
})
|
package/src/storage/buckets.ts
CHANGED
|
@@ -266,7 +266,8 @@ export function resolveBuckets(
|
|
|
266
266
|
input: StorageConfigInput | undefined,
|
|
267
267
|
env: Record<string, string | undefined> = process.env,
|
|
268
268
|
): ResolvedStorageBuckets {
|
|
269
|
-
const sharedBackend =
|
|
269
|
+
const sharedBackend =
|
|
270
|
+
platformS3Backend(env) ?? resolveSharedBackend(input, env)
|
|
270
271
|
const bucketsInput = input?.buckets
|
|
271
272
|
|
|
272
273
|
const declaredNames = bucketsInput ? Object.keys(bucketsInput) : []
|
package/src/storage/file-meta.ts
CHANGED
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
|
|
30
30
|
import { eq, and, lt, sql } from 'drizzle-orm'
|
|
31
31
|
|
|
32
|
-
import type { AnyDb } from '../dialect'
|
|
33
32
|
import type { ScopeMap } from '../access'
|
|
33
|
+
import type { AnyDb } from '../dialect'
|
|
34
34
|
|
|
35
35
|
import { rowMatchesScope } from '../access'
|
|
36
36
|
import { bunderstackFiles, filesTableFor } from '../internal-tables'
|
package/src/storage/router.ts
CHANGED
|
@@ -27,11 +27,7 @@ import {
|
|
|
27
27
|
sumReadySize,
|
|
28
28
|
type FileMetaRow,
|
|
29
29
|
} from './file-meta'
|
|
30
|
-
import {
|
|
31
|
-
parseTransformSpec,
|
|
32
|
-
transformHash,
|
|
33
|
-
transformImage,
|
|
34
|
-
} from './thumbnails'
|
|
30
|
+
import { parseTransformSpec, transformHash, transformImage } from './thumbnails'
|
|
35
31
|
|
|
36
32
|
export interface BucketStorageRouterOptions {
|
|
37
33
|
registry: BucketStorageRegistry
|
|
@@ -379,7 +375,7 @@ export function buildBucketStorageRouter(
|
|
|
379
375
|
if (!entry) return apiError(c, ErrorCode.NOT_FOUND, 'Unknown bucket', 404)
|
|
380
376
|
const { bucket, adapter } = entry
|
|
381
377
|
|
|
382
|
-
const mountPrefix = c.req.routePath.replace(/\/:[
|
|
378
|
+
const mountPrefix = c.req.routePath.replace(/\/:[^/]+\/\*$/, '')
|
|
383
379
|
const id = c.req.path.slice(`${mountPrefix}/${bucketName}/`.length)
|
|
384
380
|
const fileId = `${bucketName}/${id}`
|
|
385
381
|
|
package/src/storage/s3.ts
CHANGED
|
@@ -39,11 +39,17 @@ export class S3StorageAdapter implements StorageAdapter {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
async get(fileId: string): Promise<Response> {
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// `stat` (a HEAD, same cost as `exists`) is the only way to learn the
|
|
43
|
+
// stored Content-Type: a lazily-constructed `client.file(key)` reports
|
|
44
|
+
// `type: ""` — it never stats, and unlike `Bun.file` it does not guess
|
|
45
|
+
// from the extension either.
|
|
46
|
+
const info = await this.stat(fileId)
|
|
47
|
+
if (!info) return new Response('Not found', { status: 404 })
|
|
44
48
|
const file = this.client.file(fileId)
|
|
45
49
|
return new Response(file.stream(), {
|
|
46
|
-
headers: {
|
|
50
|
+
headers: {
|
|
51
|
+
'Content-Type': info.contentType || 'application/octet-stream',
|
|
52
|
+
},
|
|
47
53
|
})
|
|
48
54
|
}
|
|
49
55
|
|
package/src/trpc.ts
CHANGED
|
@@ -5,9 +5,9 @@ import superjson from 'superjson'
|
|
|
5
5
|
import type { AccessUser } from './access'
|
|
6
6
|
import type { DbFor } from './db'
|
|
7
7
|
import type { EmailFacade } from './email'
|
|
8
|
+
import type { StorageFacade } from './index'
|
|
8
9
|
import type { JobsRuntimeFacade } from './jobs/index'
|
|
9
10
|
import type { RealtimeFacade } from './realtime/facade'
|
|
10
|
-
import type { StorageFacade } from './index'
|
|
11
11
|
|
|
12
12
|
export type TRPCContext<
|
|
13
13
|
TSchema extends Record<string, unknown>,
|