bunderstack 0.15.1 → 0.16.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 +7 -7
- package/package.json +2 -1
- package/src/access.ts +18 -0
- package/src/blueprint-generator.ts +63 -17
- package/src/blueprint.ts +123 -30
- package/src/cli.ts +10 -2
- package/src/config.ts +22 -44
- package/src/cron.ts +2 -1
- package/src/crud.ts +28 -14
- package/src/env.ts +17 -9
- package/src/handler.ts +5 -5
- package/src/index.ts +86 -86
- package/src/internal-tables-pg.ts +1 -17
- package/src/internal-tables.ts +0 -28
- package/src/jobs/define.ts +68 -12
- package/src/jobs/index.ts +7 -9
- package/src/jobs/queue.ts +10 -6
- package/src/jobs/slots.ts +52 -0
- package/src/jobs/worker.ts +145 -45
- package/src/list-query.ts +2 -4
- package/src/manifest.ts +65 -21
- package/src/realtime/index.ts +3 -11
- package/src/realtime/redis.ts +3 -12
- package/src/routes.ts +137 -0
- 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/src/jobs/cron-auth.ts +0 -28
- package/src/jobs/cron-router.ts +0 -131
- package/src/jobs/cron-runner.ts +0 -224
- package/src/jobs/local-cron.ts +0 -78
package/README.md
CHANGED
|
@@ -87,6 +87,10 @@ import { app } from './bunderstack'
|
|
|
87
87
|
await app.runWorker()
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
Most applications need none of this: background work runs in-process by
|
|
91
|
+
default. Set `BUNDERSTACK_ROLE` to `web` or `worker` to split it across
|
|
92
|
+
processes without changing code.
|
|
93
|
+
|
|
90
94
|
If a queue handler calls `ctx.realtime.publish()`, the web and worker processes
|
|
91
95
|
must share a realtime transport. Configure `REDIS_URL` (or
|
|
92
96
|
`realtime: { redis: "redis://..." }`). `realtime: true` without Redis uses a
|
|
@@ -114,13 +118,9 @@ REDIS_URL=redis://localhost:6379 bun src/server.ts
|
|
|
114
118
|
REDIS_URL=redis://localhost:6379 bun src/worker.ts
|
|
115
119
|
```
|
|
116
120
|
|
|
117
|
-
Cron tasks (`j.cron()`) are
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
injected `BUNDERSTACK_CRON_SECRET`. Use `await app.startCronScheduler()` only
|
|
121
|
-
for local standalone development. `app.manifest.background` tells Bunderhost
|
|
122
|
-
whether to deploy an always-on worker (queue jobs) or only HTTP-delivered cron
|
|
123
|
-
(cron-only).
|
|
121
|
+
Cron tasks (`j.cron()`) are materialized as job rows keyed by their slot, so
|
|
122
|
+
they run through the same loop, retries, and timeouts as queue jobs. There is
|
|
123
|
+
no separate cron process and no signed dispatch endpoint.
|
|
124
124
|
|
|
125
125
|
### Publishing custom writes to realtime
|
|
126
126
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
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",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"test": "bun test",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
56
57
|
"dev": "bun --hot ../../examples/standalone/server.ts",
|
|
57
58
|
"db:push": "drizzle-kit push",
|
|
58
59
|
"db:migrate": "drizzle-kit migrate"
|
package/src/access.ts
CHANGED
|
@@ -103,6 +103,24 @@ export type ResolvedTableAccess = {
|
|
|
103
103
|
|
|
104
104
|
export type ResolvedAccess = Map<string, ResolvedTableAccess>
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Look up a table's resolved access by its physical table name.
|
|
108
|
+
*
|
|
109
|
+
* `ResolvedAccess` is keyed by schema export name, not table name, so every
|
|
110
|
+
* consumer that starts from a physical name needs this scan. It lives here so
|
|
111
|
+
* CRUD, realtime, and route validation cannot drift apart on which tables they
|
|
112
|
+
* consider enabled.
|
|
113
|
+
*/
|
|
114
|
+
export function tableEntryForName(
|
|
115
|
+
access: ResolvedAccess,
|
|
116
|
+
tableName: string,
|
|
117
|
+
): ResolvedTableAccess | undefined {
|
|
118
|
+
for (const entry of access.values()) {
|
|
119
|
+
if (entry.tableName === tableName) return entry
|
|
120
|
+
}
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
106
124
|
const DEFAULT_READONLY = [
|
|
107
125
|
'id',
|
|
108
126
|
'createdAt',
|
|
@@ -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/config.ts
CHANGED
|
@@ -20,29 +20,10 @@ export type BetterAuthConfig = Omit<
|
|
|
20
20
|
'database'
|
|
21
21
|
>
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.object({
|
|
28
|
-
adapter: z.unknown(),
|
|
29
|
-
url: z.string().optional(),
|
|
30
|
-
authToken: z.string().optional(),
|
|
31
|
-
migrations: z.string().optional(),
|
|
32
|
-
})
|
|
33
|
-
.optional(),
|
|
34
|
-
auth: z.record(z.string(), z.unknown()).optional(),
|
|
35
|
-
// Loose: bucket access/scope hold functions that can't survive strict zod
|
|
36
|
-
// (mirrors how `access` is loose). Resolution happens in resolveBuckets.
|
|
37
|
-
storage: z.unknown().optional(),
|
|
38
|
-
// Loose: holds zod schemas. Validation happens in validateEnv.
|
|
39
|
-
env: z.unknown().optional(),
|
|
40
|
-
// Loose: provider may be a function/adapter. Resolution happens in createEmail.
|
|
41
|
-
email: z.unknown().optional(),
|
|
42
|
-
// Loose: a tRPC router or builder callback. Resolved in createBunderstack.
|
|
43
|
-
trpc: z.unknown().optional(),
|
|
44
|
-
// Loose: holds handler functions and zod schemas. Resolved in createBunderstack.
|
|
45
|
-
jobs: z.unknown().optional(),
|
|
23
|
+
// Only the union-shaped options need runtime validation: they are the ones a
|
|
24
|
+
// JavaScript caller can plausibly get wrong in a way that fails confusingly
|
|
25
|
+
// downstream. Everything else is either typed-only or read raw from `options`.
|
|
26
|
+
const RuntimeOptionsSchema = z.object({
|
|
46
27
|
rateLimit: z
|
|
47
28
|
.union([
|
|
48
29
|
z.boolean(),
|
|
@@ -81,19 +62,7 @@ export type BunderstackConfig<
|
|
|
81
62
|
| StorageConfigInput
|
|
82
63
|
| undefined,
|
|
83
64
|
TEnv extends EnvConfigInput | undefined = EnvConfigInput | undefined,
|
|
84
|
-
> =
|
|
85
|
-
z.input<typeof BunderstackOptionsSchema>,
|
|
86
|
-
| 'schema'
|
|
87
|
-
| 'access'
|
|
88
|
-
| 'auth'
|
|
89
|
-
| 'authResolver'
|
|
90
|
-
| 'storage'
|
|
91
|
-
| 'env'
|
|
92
|
-
| 'email'
|
|
93
|
-
| 'trpc'
|
|
94
|
-
| 'jobs'
|
|
95
|
-
| 'database'
|
|
96
|
-
> & {
|
|
65
|
+
> = {
|
|
97
66
|
schema: TSchema
|
|
98
67
|
access?: TAccess
|
|
99
68
|
database: {
|
|
@@ -110,11 +79,20 @@ export type BunderstackConfig<
|
|
|
110
79
|
authResolver?: AuthSessionResolver
|
|
111
80
|
storage?: TStorage
|
|
112
81
|
env?: TEnv
|
|
82
|
+
/**
|
|
83
|
+
* Stand-in for `process.env`. Feeds both env validation and platform
|
|
84
|
+
* overrides, so tests and embedders have one injection point instead of
|
|
85
|
+
* three.
|
|
86
|
+
*/
|
|
87
|
+
processEnv?: Record<string, string | undefined>
|
|
88
|
+
background?: { autoStart?: boolean }
|
|
113
89
|
email?: EmailConfigInput
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Custom Hono routes, mounted at root ahead of bunderstack's own. Declared as
|
|
92
|
+
* a callback because routes in a separate file cannot import the app that is
|
|
93
|
+
* still being constructed — the same reason `trpc` takes a builder.
|
|
94
|
+
*/
|
|
95
|
+
routes?: (ctx: never) => unknown
|
|
118
96
|
rateLimit?: boolean | RateLimitConfig
|
|
119
97
|
idempotency?: boolean | IdempotencyConfig
|
|
120
98
|
realtime?:
|
|
@@ -154,7 +132,7 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
154
132
|
string | undefined
|
|
155
133
|
>,
|
|
156
134
|
): ResolvedConfig {
|
|
157
|
-
const parsed =
|
|
135
|
+
const parsed = RuntimeOptionsSchema.parse(options)
|
|
158
136
|
// Self-validate when the caller didn't pass a pre-validated env, so
|
|
159
137
|
// resolveConfig stays usable standalone.
|
|
160
138
|
const resolvedEnv =
|
|
@@ -173,14 +151,14 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
173
151
|
adapter,
|
|
174
152
|
url:
|
|
175
153
|
platformSource['BUNDERSTACK_DATABASE_URL'] ??
|
|
176
|
-
|
|
154
|
+
options.database?.url ??
|
|
177
155
|
resolvedEnv.DATABASE_URL ??
|
|
178
156
|
defaultUrl,
|
|
179
157
|
authToken:
|
|
180
158
|
platformSource['BUNDERSTACK_DATABASE_AUTH_TOKEN'] ??
|
|
181
|
-
|
|
159
|
+
options.database?.authToken ??
|
|
182
160
|
resolvedEnv.DATABASE_AUTH_TOKEN,
|
|
183
|
-
migrations:
|
|
161
|
+
migrations: options.database?.migrations ?? './migrations',
|
|
184
162
|
},
|
|
185
163
|
auth: (() => {
|
|
186
164
|
const authInput = options.auth ?? {}
|
package/src/cron.ts
CHANGED