bunderstack 0.13.0 → 0.15.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 +18 -12
- package/package.json +7 -2
- package/src/blueprint-generator.ts +157 -0
- package/src/blueprint.ts +188 -0
- package/src/cli.ts +82 -0
- package/src/config.ts +12 -1
- package/src/index.ts +16 -6
- package/src/jobs/cron-runner.ts +78 -6
- package/src/manifest.ts +210 -57
- package/src/provision.ts +2 -0
- package/src/realtime/redis.ts +6 -2
package/README.md
CHANGED
|
@@ -52,20 +52,25 @@ through env vars alone — no code changes required.
|
|
|
52
52
|
Plain `DATABASE_URL` / `S3_*` vars keep their usual role: fallbacks that
|
|
53
53
|
code-level config wins over.
|
|
54
54
|
|
|
55
|
-
###
|
|
55
|
+
### Committed deployment blueprint
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
Generate a deterministic, provider-neutral declaration that a host can read
|
|
58
|
+
without importing the application at deploy time. Version 1 supports TanStack
|
|
59
|
+
Start apps and records database tables and migration mode, storage buckets,
|
|
60
|
+
environment requirements, realtime, jobs, cron, and maintenance schedules.
|
|
61
61
|
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
console.log(JSON.stringify(app.manifest))
|
|
66
|
-
// { version: 2, dialect, tables, tableMap, systemTables, background, ... }
|
|
62
|
+
```sh
|
|
63
|
+
bunx bunderstack blueprint
|
|
64
|
+
bunx bunderstack blueprint --check
|
|
67
65
|
```
|
|
68
66
|
|
|
67
|
+
The command imports `src/bunderstack.ts` by default (or
|
|
68
|
+
`package.json#bunderstack.entry`) with `BUNDERSTACK_INTROSPECT=1`, so it never
|
|
69
|
+
opens external database, storage, or realtime connections. Commit the generated
|
|
70
|
+
`bunderstack.blueprint.yaml`; CI should run the `--check` form. The public
|
|
71
|
+
`bunderstack/blueprint` module exposes the strict parser and conversion helpers
|
|
72
|
+
for hosts and other tooling.
|
|
73
|
+
|
|
69
74
|
Real database clients belong to the app. Call `await app.close()` when a
|
|
70
75
|
standalone process or test is finished; it closes the real libSQL, PGlite,
|
|
71
76
|
postgres.js, or Bun SQL client selected by `database.adapter`. Introspection
|
|
@@ -93,8 +98,9 @@ queue handlers never publish realtime events, acknowledge the process-local
|
|
|
93
98
|
behavior with `app.runWorker({ allowProcessLocalRealtime: true })`.
|
|
94
99
|
|
|
95
100
|
Inspect the active runtime with `app.realtime.transport` (`'disabled'`,
|
|
96
|
-
`'memory'`, or `'redis'`).
|
|
97
|
-
|
|
101
|
+
`'memory'`, or `'redis'`). The generated blueprint declares only whether
|
|
102
|
+
realtime is required; the host chooses its shared transport and injects its
|
|
103
|
+
runtime configuration.
|
|
98
104
|
|
|
99
105
|
```ts
|
|
100
106
|
const app = await createBunderstack({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunderstack",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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",
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
"url": "git+https://github.com/kirill-dev-pro/bunderstack.git",
|
|
21
21
|
"directory": "packages/bunderstack"
|
|
22
22
|
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"bunderstack": "./src/cli.ts"
|
|
25
|
+
},
|
|
23
26
|
"files": [
|
|
24
27
|
"src",
|
|
25
28
|
"!src/**/*.test.ts",
|
|
@@ -43,6 +46,7 @@
|
|
|
43
46
|
"./typeid": "./src/typeid.ts",
|
|
44
47
|
"./typeid/pg": "./src/typeid-pg.ts",
|
|
45
48
|
"./env": "./src/env.ts",
|
|
49
|
+
"./blueprint": "./src/blueprint.ts",
|
|
46
50
|
"./trpc": "./src/trpc.ts",
|
|
47
51
|
"./cron": "./src/cron.ts",
|
|
48
52
|
"./email/smtp": "./src/email/smtp.ts"
|
|
@@ -54,7 +58,8 @@
|
|
|
54
58
|
"db:migrate": "drizzle-kit migrate"
|
|
55
59
|
},
|
|
56
60
|
"dependencies": {
|
|
57
|
-
"superjson": "^2.2.0"
|
|
61
|
+
"superjson": "^2.2.0",
|
|
62
|
+
"yaml": "^2.9.0"
|
|
58
63
|
},
|
|
59
64
|
"devDependencies": {
|
|
60
65
|
"@electric-sql/pglite": ">=0.3.0",
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
blueprintFromManifest,
|
|
7
|
+
serializeBlueprint,
|
|
8
|
+
type BunderstackBlueprint,
|
|
9
|
+
} from './blueprint'
|
|
10
|
+
import { parseManifest } from './manifest'
|
|
11
|
+
|
|
12
|
+
export type GenerateBlueprintOptions = {
|
|
13
|
+
directory: string
|
|
14
|
+
entry?: string
|
|
15
|
+
output?: string
|
|
16
|
+
check?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type GenerateBlueprintResult = {
|
|
20
|
+
path: string
|
|
21
|
+
blueprint: BunderstackBlueprint
|
|
22
|
+
source: string
|
|
23
|
+
changed: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class BlueprintCheckError extends Error {
|
|
27
|
+
constructor() {
|
|
28
|
+
super('bunderstack.blueprint.yaml is missing or stale; run `bunderstack blueprint`')
|
|
29
|
+
this.name = 'BlueprintCheckError'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type AppPackage = {
|
|
34
|
+
scripts?: Record<string, unknown>
|
|
35
|
+
dependencies?: Record<string, unknown>
|
|
36
|
+
devDependencies?: Record<string, unknown>
|
|
37
|
+
bunderstack?: { entry?: unknown }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function requireRelativePath(value: string, label: string): string {
|
|
41
|
+
const normalized = value.replaceAll('\\', '/')
|
|
42
|
+
if (
|
|
43
|
+
!normalized ||
|
|
44
|
+
isAbsolute(normalized) ||
|
|
45
|
+
normalized.split('/').some((part) => !part || part === '..')
|
|
46
|
+
) {
|
|
47
|
+
throw new Error(`[bunderstack] ${label} must be a relative path without traversal`)
|
|
48
|
+
}
|
|
49
|
+
return normalized
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveWithin(root: string, value: string, label: string): string {
|
|
53
|
+
const path = resolve(root, requireRelativePath(value, label))
|
|
54
|
+
const pathFromRoot = relative(root, path)
|
|
55
|
+
if (pathFromRoot === '..' || pathFromRoot.startsWith('../') || isAbsolute(pathFromRoot)) {
|
|
56
|
+
throw new Error(`[bunderstack] ${label} must stay within the application directory`)
|
|
57
|
+
}
|
|
58
|
+
return path
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeProjectPath(root: string, value: string, label: string): string {
|
|
62
|
+
if (!isAbsolute(value)) return requireRelativePath(value, label)
|
|
63
|
+
const pathFromRoot = relative(root, resolve(value)) || '.'
|
|
64
|
+
return requireRelativePath(pathFromRoot, label)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requireScript(pkg: AppPackage, name: 'build' | 'start' | 'worker', required: boolean): boolean {
|
|
68
|
+
const value = pkg.scripts?.[name]
|
|
69
|
+
if (typeof value === 'string' && value.trim()) return true
|
|
70
|
+
if (required) throw new Error(`[bunderstack] package.json requires a non-empty "${name}" script`)
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function packageVersion(): Promise<string> {
|
|
75
|
+
const pkg = (await Bun.file(new URL('../package.json', import.meta.url)).json()) as { version: string }
|
|
76
|
+
return pkg.version
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function generateBlueprint(
|
|
80
|
+
options: GenerateBlueprintOptions,
|
|
81
|
+
): Promise<GenerateBlueprintResult> {
|
|
82
|
+
const directory = await realpath(resolve(options.directory))
|
|
83
|
+
const packagePath = join(directory, 'package.json')
|
|
84
|
+
const pkg = JSON.parse(await readFile(packagePath, 'utf8')) as AppPackage
|
|
85
|
+
const allDependencies = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
86
|
+
if (typeof allDependencies['@tanstack/react-start'] !== 'string') {
|
|
87
|
+
throw new Error('[bunderstack] package.json must depend on @tanstack/react-start')
|
|
88
|
+
}
|
|
89
|
+
requireScript(pkg, 'build', true)
|
|
90
|
+
requireScript(pkg, 'start', true)
|
|
91
|
+
|
|
92
|
+
const configuredEntry = pkg.bunderstack?.entry
|
|
93
|
+
const entry = requireRelativePath(
|
|
94
|
+
options.entry ?? (typeof configuredEntry === 'string' ? configuredEntry : 'src/bunderstack.ts'),
|
|
95
|
+
'entry',
|
|
96
|
+
)
|
|
97
|
+
const entryPath = resolveWithin(directory, entry, 'entry')
|
|
98
|
+
if (!(await Bun.file(entryPath).exists())) {
|
|
99
|
+
throw new Error(`[bunderstack] entry does not exist: ${entry}`)
|
|
100
|
+
}
|
|
101
|
+
const output = requireRelativePath(options.output ?? 'bunderstack.blueprint.yaml', 'output')
|
|
102
|
+
const outputPath = resolveWithin(directory, output, 'output')
|
|
103
|
+
|
|
104
|
+
const previousIntrospection = process.env.BUNDERSTACK_INTROSPECT
|
|
105
|
+
process.env.BUNDERSTACK_INTROSPECT = '1'
|
|
106
|
+
let app: { manifest?: unknown; close?: () => Promise<void> } | undefined
|
|
107
|
+
try {
|
|
108
|
+
const module = (await import(`${pathToFileURL(entryPath).href}?blueprint=${Date.now()}`)) as {
|
|
109
|
+
app?: typeof app
|
|
110
|
+
}
|
|
111
|
+
app = module.app
|
|
112
|
+
if (!app) throw new Error(`[bunderstack] ${entry} must export app`)
|
|
113
|
+
const manifest = parseManifest(app.manifest)
|
|
114
|
+
const workerRequired = manifest.background.jobs.length > 0
|
|
115
|
+
requireScript(pkg, 'worker', workerRequired)
|
|
116
|
+
const migrationsDirectory = normalizeProjectPath(
|
|
117
|
+
directory,
|
|
118
|
+
manifest.database.migrationsDirectory,
|
|
119
|
+
'migrationsDirectory',
|
|
120
|
+
)
|
|
121
|
+
const migrationJournal = join(
|
|
122
|
+
resolveWithin(directory, migrationsDirectory, 'migrationsDirectory'),
|
|
123
|
+
'meta',
|
|
124
|
+
'_journal.json',
|
|
125
|
+
)
|
|
126
|
+
const migrationMode = (await Bun.file(migrationJournal).exists()) ? 'migrations' : 'push'
|
|
127
|
+
const blueprint = blueprintFromManifest({
|
|
128
|
+
manifest: {
|
|
129
|
+
...manifest,
|
|
130
|
+
database: { ...manifest.database, migrationsDirectory },
|
|
131
|
+
},
|
|
132
|
+
generatorVersion: await packageVersion(),
|
|
133
|
+
entry,
|
|
134
|
+
migrationMode,
|
|
135
|
+
})
|
|
136
|
+
const source = serializeBlueprint(blueprint)
|
|
137
|
+
const existing = (await Bun.file(outputPath).exists()) ? await Bun.file(outputPath).text() : undefined
|
|
138
|
+
if (options.check) {
|
|
139
|
+
if (existing !== source) throw new BlueprintCheckError()
|
|
140
|
+
return { path: outputPath, blueprint, source, changed: false }
|
|
141
|
+
}
|
|
142
|
+
if (existing === source) return { path: outputPath, blueprint, source, changed: false }
|
|
143
|
+
await mkdir(dirname(outputPath), { recursive: true })
|
|
144
|
+
const temporary = `${outputPath}.${process.pid}.${Date.now()}.tmp`
|
|
145
|
+
try {
|
|
146
|
+
await writeFile(temporary, source, { mode: 0o600 })
|
|
147
|
+
await rename(temporary, outputPath)
|
|
148
|
+
} finally {
|
|
149
|
+
await rm(temporary, { force: true })
|
|
150
|
+
}
|
|
151
|
+
return { path: outputPath, blueprint, source, changed: true }
|
|
152
|
+
} finally {
|
|
153
|
+
await app?.close?.()
|
|
154
|
+
if (previousIntrospection === undefined) delete process.env.BUNDERSTACK_INTROSPECT
|
|
155
|
+
else process.env.BUNDERSTACK_INTROSPECT = previousIntrospection
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/blueprint.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { parse, stringify } from 'yaml'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import { parseCron } from './jobs/cron'
|
|
5
|
+
import type { BunderstackManifest } from './manifest'
|
|
6
|
+
|
|
7
|
+
export type MigrationMode = 'migrations' | 'push'
|
|
8
|
+
|
|
9
|
+
export type BunderstackBlueprint = {
|
|
10
|
+
version: 1
|
|
11
|
+
generator: { name: 'bunderstack'; version: string }
|
|
12
|
+
application: {
|
|
13
|
+
framework: 'tanstack-start'
|
|
14
|
+
scripts: { build: 'build'; start: 'start'; worker?: 'worker' }
|
|
15
|
+
}
|
|
16
|
+
bunderstack: { entry: string; manifestVersion: 3 }
|
|
17
|
+
resources: {
|
|
18
|
+
database: BunderstackManifest['database'] & { migrationMode: MigrationMode }
|
|
19
|
+
storage: BunderstackManifest['storage']
|
|
20
|
+
realtime?: { required: true }
|
|
21
|
+
}
|
|
22
|
+
environment: BunderstackManifest['environment']
|
|
23
|
+
background: BunderstackManifest['background'] & {
|
|
24
|
+
worker: { required: boolean }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const nonEmpty = z.string().min(1)
|
|
29
|
+
const relativePath = nonEmpty.refine(
|
|
30
|
+
(value) =>
|
|
31
|
+
!value.startsWith('/') &&
|
|
32
|
+
!value.includes('\\') &&
|
|
33
|
+
value.split('/').every((part) => part !== '' && part !== '..'),
|
|
34
|
+
{ message: 'entry must be a relative path without traversal' },
|
|
35
|
+
)
|
|
36
|
+
const cronSchedule = nonEmpty.refine(
|
|
37
|
+
(value) => {
|
|
38
|
+
try {
|
|
39
|
+
parseCron(value)
|
|
40
|
+
return true
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{ message: 'invalid cron schedule' },
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const blueprintSchema = z
|
|
49
|
+
.object({
|
|
50
|
+
version: z.literal(1),
|
|
51
|
+
generator: z.object({ name: z.literal('bunderstack'), version: nonEmpty }).strict(),
|
|
52
|
+
application: z
|
|
53
|
+
.object({
|
|
54
|
+
framework: z.literal('tanstack-start'),
|
|
55
|
+
scripts: z
|
|
56
|
+
.object({ build: z.literal('build'), start: z.literal('start'), worker: z.literal('worker').optional() })
|
|
57
|
+
.strict(),
|
|
58
|
+
})
|
|
59
|
+
.strict(),
|
|
60
|
+
bunderstack: z
|
|
61
|
+
.object({ entry: relativePath, manifestVersion: z.literal(3) })
|
|
62
|
+
.strict(),
|
|
63
|
+
resources: z
|
|
64
|
+
.object({
|
|
65
|
+
database: z
|
|
66
|
+
.object({
|
|
67
|
+
dialect: z.enum(['sqlite', 'pg']),
|
|
68
|
+
migrationsDirectory: relativePath,
|
|
69
|
+
migrationMode: z.enum(['migrations', 'push']),
|
|
70
|
+
tables: z.array(
|
|
71
|
+
z.object({ exportName: nonEmpty, physicalName: nonEmpty, system: z.boolean() }).strict(),
|
|
72
|
+
),
|
|
73
|
+
})
|
|
74
|
+
.strict(),
|
|
75
|
+
storage: z
|
|
76
|
+
.object({
|
|
77
|
+
defaultBucket: nonEmpty,
|
|
78
|
+
buckets: z.array(z.object({ name: nonEmpty, visibility: z.enum(['public', 'private']) }).strict()),
|
|
79
|
+
})
|
|
80
|
+
.strict(),
|
|
81
|
+
realtime: z.object({ required: z.literal(true) }).strict().optional(),
|
|
82
|
+
})
|
|
83
|
+
.strict(),
|
|
84
|
+
environment: z.array(
|
|
85
|
+
z.object({ key: nonEmpty, required: z.boolean(), scope: z.enum(['server', 'client']) }).strict(),
|
|
86
|
+
),
|
|
87
|
+
background: z
|
|
88
|
+
.object({
|
|
89
|
+
worker: z.object({ required: z.boolean() }).strict(),
|
|
90
|
+
jobs: z.array(z.object({ name: nonEmpty }).strict()),
|
|
91
|
+
cron: z.array(z.object({ name: nonEmpty, schedule: cronSchedule, timezone: z.literal('UTC') }).strict()),
|
|
92
|
+
maintenance: z.array(
|
|
93
|
+
z.object({ name: z.literal('storage-sweep'), schedule: cronSchedule, timezone: z.literal('UTC') }).strict(),
|
|
94
|
+
),
|
|
95
|
+
})
|
|
96
|
+
.strict(),
|
|
97
|
+
})
|
|
98
|
+
.strict()
|
|
99
|
+
|
|
100
|
+
function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
|
|
101
|
+
return [...entries].sort((left, right) => key(left).localeCompare(key(right)))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function rejectDuplicates(collection: string, values: readonly string[]): void {
|
|
105
|
+
const seen = new Set<string>()
|
|
106
|
+
for (const value of values) {
|
|
107
|
+
if (seen.has(value)) throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
|
|
108
|
+
seen.add(value)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function parseBlueprint(value: unknown): BunderstackBlueprint {
|
|
113
|
+
const blueprint = blueprintSchema.parse(value) as BunderstackBlueprint
|
|
114
|
+
rejectDuplicates('database physical table', blueprint.resources.database.tables.map((entry) => entry.physicalName))
|
|
115
|
+
rejectDuplicates('database export table', blueprint.resources.database.tables.map((entry) => entry.exportName))
|
|
116
|
+
rejectDuplicates('storage bucket', blueprint.resources.storage.buckets.map((entry) => entry.name))
|
|
117
|
+
rejectDuplicates('environment key', blueprint.environment.map((entry) => entry.key))
|
|
118
|
+
rejectDuplicates('background job', blueprint.background.jobs.map((entry) => entry.name))
|
|
119
|
+
rejectDuplicates('background cron', blueprint.background.cron.map((entry) => entry.name))
|
|
120
|
+
rejectDuplicates('background maintenance', blueprint.background.maintenance.map((entry) => entry.name))
|
|
121
|
+
if (!blueprint.resources.storage.buckets.some((bucket) => bucket.name === blueprint.resources.storage.defaultBucket)) {
|
|
122
|
+
throw new Error('[bunderstack] storage defaultBucket must be declared in storage buckets')
|
|
123
|
+
}
|
|
124
|
+
const workerRequired = blueprint.background.jobs.length > 0
|
|
125
|
+
if (blueprint.background.worker.required !== workerRequired) {
|
|
126
|
+
throw new Error('[bunderstack] background worker.required must match declared queue jobs')
|
|
127
|
+
}
|
|
128
|
+
if (Boolean(blueprint.application.scripts.worker) !== workerRequired) {
|
|
129
|
+
throw new Error('[bunderstack] application worker script must match declared queue jobs')
|
|
130
|
+
}
|
|
131
|
+
return blueprint
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function blueprintFromManifest(args: {
|
|
135
|
+
manifest: BunderstackManifest
|
|
136
|
+
generatorVersion: string
|
|
137
|
+
entry: string
|
|
138
|
+
migrationMode: MigrationMode
|
|
139
|
+
}): BunderstackBlueprint {
|
|
140
|
+
const workerRequired = args.manifest.background.jobs.length > 0
|
|
141
|
+
return parseBlueprint({
|
|
142
|
+
version: 1,
|
|
143
|
+
generator: { name: 'bunderstack', version: args.generatorVersion },
|
|
144
|
+
application: {
|
|
145
|
+
framework: 'tanstack-start',
|
|
146
|
+
scripts: { build: 'build', start: 'start', ...(workerRequired ? { worker: 'worker' } : {}) },
|
|
147
|
+
},
|
|
148
|
+
bunderstack: { entry: args.entry, manifestVersion: 3 },
|
|
149
|
+
resources: {
|
|
150
|
+
database: {
|
|
151
|
+
...args.manifest.database,
|
|
152
|
+
migrationMode: args.migrationMode,
|
|
153
|
+
tables: sortBy(args.manifest.database.tables, (entry) => entry.physicalName),
|
|
154
|
+
},
|
|
155
|
+
storage: {
|
|
156
|
+
...args.manifest.storage,
|
|
157
|
+
buckets: sortBy(args.manifest.storage.buckets, (entry) => entry.name),
|
|
158
|
+
},
|
|
159
|
+
...(args.manifest.realtime.required ? { realtime: { required: true } } : {}),
|
|
160
|
+
},
|
|
161
|
+
environment: sortBy(args.manifest.environment, (entry) => entry.key),
|
|
162
|
+
background: {
|
|
163
|
+
worker: { required: workerRequired },
|
|
164
|
+
jobs: sortBy(args.manifest.background.jobs, (entry) => entry.name),
|
|
165
|
+
cron: sortBy(args.manifest.background.cron, (entry) => entry.name),
|
|
166
|
+
maintenance: sortBy(args.manifest.background.maintenance, (entry) => entry.name),
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function parseBlueprintYaml(source: string): BunderstackBlueprint {
|
|
172
|
+
return parseBlueprint(parse(source) as unknown)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function serializeBlueprint(value: BunderstackBlueprint): string {
|
|
176
|
+
const blueprint = parseBlueprint(value)
|
|
177
|
+
const options = {
|
|
178
|
+
aliasDuplicateObjects: false,
|
|
179
|
+
defaultKeyType: 'PLAIN',
|
|
180
|
+
defaultStringType: 'PLAIN',
|
|
181
|
+
lineWidth: 0,
|
|
182
|
+
} as const
|
|
183
|
+
const source = stringify(parseBlueprintYaml(stringify(blueprint, options)), options)
|
|
184
|
+
return source.replace(/^(\s*schedule: )(.+)$/gm, (_match, prefix: string, value: string) => {
|
|
185
|
+
const schedule = value.startsWith('"') ? JSON.parse(value) : value
|
|
186
|
+
return `${prefix}${JSON.stringify(schedule)}`
|
|
187
|
+
})
|
|
188
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import {
|
|
3
|
+
BlueprintCheckError,
|
|
4
|
+
generateBlueprint,
|
|
5
|
+
type GenerateBlueprintOptions,
|
|
6
|
+
} from './blueprint-generator'
|
|
7
|
+
|
|
8
|
+
export type CliIo = {
|
|
9
|
+
stdout(message: string): void
|
|
10
|
+
stderr(message: string): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const help = `Usage: bunderstack blueprint [directory] [--entry <path>] [--output <path>] [--check]
|
|
14
|
+
|
|
15
|
+
Generate a committed deployment declaration for a TanStack Start application.
|
|
16
|
+
Entry precedence: --entry, package.json#bunderstack.entry, src/bunderstack.ts.`
|
|
17
|
+
|
|
18
|
+
export async function runCli(
|
|
19
|
+
args: string[],
|
|
20
|
+
io: CliIo,
|
|
21
|
+
generate: typeof generateBlueprint = generateBlueprint,
|
|
22
|
+
): Promise<number> {
|
|
23
|
+
if (args[0] === '--help' || args[0] === '-h') {
|
|
24
|
+
io.stdout(help)
|
|
25
|
+
return 0
|
|
26
|
+
}
|
|
27
|
+
if (args[0] === '--version') {
|
|
28
|
+
io.stdout((await Bun.file(new URL('../package.json', import.meta.url)).json() as { version: string }).version)
|
|
29
|
+
return 0
|
|
30
|
+
}
|
|
31
|
+
if (args[0] !== 'blueprint') {
|
|
32
|
+
io.stderr('Usage: bunderstack blueprint [directory] [--entry <path>] [--output <path>] [--check]')
|
|
33
|
+
return 2
|
|
34
|
+
}
|
|
35
|
+
const options: GenerateBlueprintOptions = { directory: process.cwd() }
|
|
36
|
+
for (let index = 1; index < args.length; index++) {
|
|
37
|
+
const argument = args[index]!
|
|
38
|
+
if (argument === '--check') {
|
|
39
|
+
options.check = true
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (argument === '--entry' || argument === '--output') {
|
|
43
|
+
const value = args[++index]
|
|
44
|
+
if (!value || value.startsWith('--')) {
|
|
45
|
+
io.stderr(`[bunderstack] missing value for ${argument}`)
|
|
46
|
+
return 2
|
|
47
|
+
}
|
|
48
|
+
if (argument === '--entry') options.entry = value
|
|
49
|
+
else options.output = value
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (argument.startsWith('-')) {
|
|
53
|
+
io.stderr(`[bunderstack] unknown option: ${argument}`)
|
|
54
|
+
return 2
|
|
55
|
+
}
|
|
56
|
+
if (options.directory !== process.cwd()) {
|
|
57
|
+
io.stderr('[bunderstack] only one application directory is allowed')
|
|
58
|
+
return 2
|
|
59
|
+
}
|
|
60
|
+
options.directory = argument
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const result = await generate(options)
|
|
64
|
+
io.stdout(
|
|
65
|
+
options.check || !result.changed
|
|
66
|
+
? 'bunderstack.blueprint.yaml is current'
|
|
67
|
+
: 'Generated bunderstack.blueprint.yaml',
|
|
68
|
+
)
|
|
69
|
+
return 0
|
|
70
|
+
} catch (error) {
|
|
71
|
+
io.stderr(error instanceof Error ? error.message : String(error))
|
|
72
|
+
return error instanceof BlueprintCheckError ? 1 : 1
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (import.meta.main) {
|
|
77
|
+
const exitCode = await runCli(process.argv.slice(2), {
|
|
78
|
+
stdout: (message) => console.log(message),
|
|
79
|
+
stderr: (message) => console.error(message),
|
|
80
|
+
})
|
|
81
|
+
process.exit(exitCode)
|
|
82
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -197,12 +197,23 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
197
197
|
export function resolveRealtimeRedisUrl(
|
|
198
198
|
realtime: ResolvedConfig['realtime'],
|
|
199
199
|
env?: BaseEnv,
|
|
200
|
+
platformSource: Record<string, string | undefined> = process.env as Record<
|
|
201
|
+
string,
|
|
202
|
+
string | undefined
|
|
203
|
+
>,
|
|
200
204
|
): string | undefined {
|
|
205
|
+
const platformRedis = platformSource['REDIS_URL']
|
|
206
|
+
if (platformRedis) return platformRedis
|
|
207
|
+
|
|
208
|
+
const envRedis = env?.REDIS_URL
|
|
209
|
+
if (envRedis) return envRedis
|
|
210
|
+
|
|
201
211
|
const fromConfig =
|
|
202
212
|
typeof realtime === 'object' && realtime.redis
|
|
203
213
|
? typeof realtime.redis === 'string'
|
|
204
214
|
? realtime.redis
|
|
205
215
|
: realtime.redis.url
|
|
206
216
|
: undefined
|
|
207
|
-
|
|
217
|
+
|
|
218
|
+
return fromConfig ?? undefined
|
|
208
219
|
}
|
package/src/index.ts
CHANGED
|
@@ -407,6 +407,7 @@ export async function createBunderstack<
|
|
|
407
407
|
? redisUrl
|
|
408
408
|
? createRedisRealtimeBroker({
|
|
409
409
|
access: resolvedAccess,
|
|
410
|
+
channel: process.env.BUNDERSTACK_REALTIME_CHANNEL || undefined,
|
|
410
411
|
redis: () => {
|
|
411
412
|
// Redis pub/sub requires a dedicated connection (subscribe puts the client into
|
|
412
413
|
// a restricted state). We use one client for commands and a second for subscribe.
|
|
@@ -487,9 +488,7 @@ export async function createBunderstack<
|
|
|
487
488
|
},
|
|
488
489
|
async getUrl(key, opts = {}) {
|
|
489
490
|
const bucketName =
|
|
490
|
-
opts.bucket ??
|
|
491
|
-
key.split('/')[0] ??
|
|
492
|
-
config.storage.defaultBucket
|
|
491
|
+
opts.bucket ?? key.split('/')[0] ?? config.storage.defaultBucket
|
|
493
492
|
const adapter = registry.get(bucketName)?.adapter
|
|
494
493
|
if (adapter?.presignGet) {
|
|
495
494
|
return adapter.presignGet(key, {
|
|
@@ -503,7 +502,10 @@ export async function createBunderstack<
|
|
|
503
502
|
const adapter = registry.get(bucketName)?.adapter
|
|
504
503
|
if (!adapter) throw new Error(`Unknown bucket: ${bucketName}`)
|
|
505
504
|
const u8 = body instanceof Uint8Array ? body : new Uint8Array(body)
|
|
506
|
-
const buf: ArrayBuffer = u8.buffer.slice(
|
|
505
|
+
const buf: ArrayBuffer = u8.buffer.slice(
|
|
506
|
+
u8.byteOffset,
|
|
507
|
+
u8.byteOffset + u8.byteLength,
|
|
508
|
+
) as ArrayBuffer
|
|
507
509
|
await adapter.upload(key, buf, contentType)
|
|
508
510
|
await insertReadyFile(db, {
|
|
509
511
|
fileId: key,
|
|
@@ -541,6 +543,9 @@ export async function createBunderstack<
|
|
|
541
543
|
const startWorker = async (
|
|
542
544
|
options: AppStartWorkerOptions = {},
|
|
543
545
|
): Promise<WorkerHandle> => {
|
|
546
|
+
if (introspect) {
|
|
547
|
+
return { closed: Promise.resolve(), close: async () => {} }
|
|
548
|
+
}
|
|
544
549
|
if (!jobRunner) {
|
|
545
550
|
throw new Error('[bunderstack] no queue jobs configured')
|
|
546
551
|
}
|
|
@@ -562,6 +567,9 @@ export async function createBunderstack<
|
|
|
562
567
|
const startCronScheduler = async (
|
|
563
568
|
options: AppStartCronSchedulerOptions = {},
|
|
564
569
|
): Promise<LocalCronScheduler> => {
|
|
570
|
+
if (introspect) {
|
|
571
|
+
return { tick: async () => {}, close: async () => {} }
|
|
572
|
+
}
|
|
565
573
|
const cron = Object.entries(jobsDefs ?? {}).flatMap(
|
|
566
574
|
([name, definition]) =>
|
|
567
575
|
definition.kind === 'cron'
|
|
@@ -601,6 +609,7 @@ export async function createBunderstack<
|
|
|
601
609
|
const runWorker = async (
|
|
602
610
|
options: AppRunWorkerOptions = {},
|
|
603
611
|
): Promise<void> => {
|
|
612
|
+
if (introspect) return
|
|
604
613
|
if (
|
|
605
614
|
realtime.transport === 'memory' &&
|
|
606
615
|
!options.allowProcessLocalRealtime
|
|
@@ -698,10 +707,11 @@ export async function createBunderstack<
|
|
|
698
707
|
manifest: buildManifest({
|
|
699
708
|
schema: options.schema,
|
|
700
709
|
dialect,
|
|
710
|
+
migrationsDirectory: config.database.migrations,
|
|
701
711
|
storage: config.storage,
|
|
702
712
|
envConfig: options.env as EnvConfigInput | undefined,
|
|
713
|
+
emailProvider: emailProviderTag(options.email),
|
|
703
714
|
realtime: Boolean(config.realtime),
|
|
704
|
-
realtimeTransport: configuredRealtimeTransport,
|
|
705
715
|
jobs: jobsDefs,
|
|
706
716
|
}),
|
|
707
717
|
}
|
|
@@ -742,7 +752,7 @@ export type {
|
|
|
742
752
|
} from './config'
|
|
743
753
|
export { validateEnv, createClientEnv, BunderstackEnvError } from './env'
|
|
744
754
|
export type { EnvConfigInput, BaseEnv, ValidatedEnv } from './env'
|
|
745
|
-
export { buildManifest } from './manifest'
|
|
755
|
+
export { buildManifest, parseManifest } from './manifest'
|
|
746
756
|
export type { BunderstackManifest, ManifestEnvVar } from './manifest'
|
|
747
757
|
export { createEmail } from './email'
|
|
748
758
|
export type {
|
package/src/jobs/cron-runner.ts
CHANGED
|
@@ -20,14 +20,20 @@ export async function runScheduledSlot(args: {
|
|
|
20
20
|
slot: number
|
|
21
21
|
now: number
|
|
22
22
|
run: (scheduledFor: Date) => Promise<void> | void
|
|
23
|
+
leaseMs?: number
|
|
24
|
+
heartbeatIntervalMs?: number
|
|
23
25
|
}): Promise<CronRunResult> {
|
|
24
26
|
const { db, taskId, schedule, slot, now, run } = args
|
|
27
|
+
const leaseMs = args.leaseMs ?? LEASE_MS
|
|
28
|
+
const heartbeatIntervalMs =
|
|
29
|
+
args.heartbeatIntervalMs ?? Math.max(1, Math.floor(leaseMs / 4))
|
|
30
|
+
|
|
25
31
|
if (slot % 60_000 !== 0 || !cronMatches(parseCron(schedule), slot)) {
|
|
26
32
|
throw new Error('[bunderstack] cron slot does not match its schedule')
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
const t = cronRunsTableFor(db)
|
|
30
|
-
const leaseUntil = now +
|
|
36
|
+
const leaseUntil = now + leaseMs
|
|
31
37
|
const inserted = await db
|
|
32
38
|
.insert(t)
|
|
33
39
|
.values({
|
|
@@ -72,20 +78,81 @@ export async function runScheduledSlot(args: {
|
|
|
72
78
|
if (!reclaimed[0]) return { status: 'running' }
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
let heartbeatTimer: Timer | undefined
|
|
82
|
+
const stopHeartbeat = () => {
|
|
83
|
+
if (heartbeatTimer) {
|
|
84
|
+
clearInterval(heartbeatTimer)
|
|
85
|
+
heartbeatTimer = undefined
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
heartbeatTimer = setInterval(async () => {
|
|
90
|
+
try {
|
|
91
|
+
const renewUntil = Date.now() + leaseMs
|
|
92
|
+
await db
|
|
93
|
+
.update(t)
|
|
94
|
+
.set({ lockedUntil: renewUntil })
|
|
95
|
+
.where(
|
|
96
|
+
and(
|
|
97
|
+
eq(t.taskId, taskId),
|
|
98
|
+
eq(t.scheduledAt, slot),
|
|
99
|
+
eq(t.status, 'running'),
|
|
100
|
+
eq(t.startedAt, now),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
} catch {
|
|
104
|
+
// Best effort renewal
|
|
105
|
+
}
|
|
106
|
+
}, heartbeatIntervalMs)
|
|
107
|
+
|
|
75
108
|
try {
|
|
76
109
|
await run(new Date(slot))
|
|
77
|
-
|
|
110
|
+
|
|
111
|
+
stopHeartbeat()
|
|
112
|
+
|
|
113
|
+
const updated = await db
|
|
78
114
|
.update(t)
|
|
79
115
|
.set({ status: 'succeeded', lockedUntil: null, finishedAt: Date.now() })
|
|
80
|
-
.where(
|
|
116
|
+
.where(
|
|
117
|
+
and(
|
|
118
|
+
eq(t.taskId, taskId),
|
|
119
|
+
eq(t.scheduledAt, slot),
|
|
120
|
+
eq(t.status, 'running'),
|
|
121
|
+
eq(t.startedAt, now),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
.returning({ taskId: t.taskId })
|
|
125
|
+
|
|
126
|
+
if (!updated[0]) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'[bunderstack] cron lease ownership was lost during execution',
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
81
132
|
return { status: 'succeeded' }
|
|
82
133
|
} catch (error) {
|
|
134
|
+
stopHeartbeat()
|
|
135
|
+
|
|
83
136
|
const message = error instanceof Error ? error.message : String(error)
|
|
84
137
|
await db
|
|
85
138
|
.update(t)
|
|
86
|
-
.set({
|
|
87
|
-
|
|
139
|
+
.set({
|
|
140
|
+
status: 'failed',
|
|
141
|
+
lockedUntil: null,
|
|
142
|
+
lastError: message,
|
|
143
|
+
finishedAt: Date.now(),
|
|
144
|
+
})
|
|
145
|
+
.where(
|
|
146
|
+
and(
|
|
147
|
+
eq(t.taskId, taskId),
|
|
148
|
+
eq(t.scheduledAt, slot),
|
|
149
|
+
eq(t.status, 'running'),
|
|
150
|
+
eq(t.startedAt, now),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
88
153
|
throw error
|
|
154
|
+
} finally {
|
|
155
|
+
stopHeartbeat()
|
|
89
156
|
}
|
|
90
157
|
}
|
|
91
158
|
|
|
@@ -96,6 +163,8 @@ export async function runCronSlot(args: {
|
|
|
96
163
|
name: string
|
|
97
164
|
slot: number
|
|
98
165
|
now: number
|
|
166
|
+
leaseMs?: number
|
|
167
|
+
heartbeatIntervalMs?: number
|
|
99
168
|
}): Promise<CronRunResult> {
|
|
100
169
|
const definition = args.defs[args.name]
|
|
101
170
|
if (!definition || definition.kind !== 'cron') {
|
|
@@ -107,6 +176,9 @@ export async function runCronSlot(args: {
|
|
|
107
176
|
schedule: definition.schedule,
|
|
108
177
|
slot: args.slot,
|
|
109
178
|
now: args.now,
|
|
110
|
-
|
|
179
|
+
leaseMs: args.leaseMs,
|
|
180
|
+
heartbeatIntervalMs: args.heartbeatIntervalMs,
|
|
181
|
+
run: (scheduledFor) =>
|
|
182
|
+
definition.handler({ scheduledFor }, args.ctx as never),
|
|
111
183
|
})
|
|
112
184
|
}
|
package/src/manifest.ts
CHANGED
|
@@ -1,103 +1,256 @@
|
|
|
1
|
-
// src/manifest.ts — deploy-time introspection surface. Pure: consumes already
|
|
2
|
-
// resolved config pieces, never reads process.env or touches the network.
|
|
3
|
-
// Deployment platforms (Bunderhost) import the app declaration with
|
|
4
|
-
// BUNDERSTACK_INTROSPECT=1 and read `app.manifest` to learn what to provision.
|
|
5
|
-
import type { ZodType } from 'zod'
|
|
6
1
|
import { getTableName, isTable } from 'drizzle-orm'
|
|
2
|
+
import { z, type ZodType } from 'zod'
|
|
7
3
|
|
|
8
4
|
import type { Dialect } from './dialect'
|
|
9
5
|
import type { EnvConfigInput } from './env'
|
|
10
6
|
import type { JobsDefs } from './jobs/define'
|
|
11
|
-
import type { RealtimeTransport } from './realtime/facade'
|
|
12
7
|
import type { ResolvedBucket, ResolvedStorageBuckets } from './storage/buckets'
|
|
8
|
+
import { parseCron } from './jobs/cron'
|
|
13
9
|
import {
|
|
14
10
|
bunderstackCronRuns,
|
|
15
11
|
bunderstackFiles,
|
|
12
|
+
bunderstackIdempotency,
|
|
16
13
|
bunderstackJobs,
|
|
17
14
|
} from './internal-tables'
|
|
18
15
|
|
|
19
|
-
export type ManifestEnvVar = {
|
|
16
|
+
export type ManifestEnvVar = {
|
|
17
|
+
key: string
|
|
18
|
+
required: boolean
|
|
19
|
+
scope: 'server' | 'client'
|
|
20
|
+
}
|
|
21
|
+
|
|
20
22
|
export type BunderstackManifest = {
|
|
21
|
-
version:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
jobs: string
|
|
27
|
-
files: string
|
|
28
|
-
scheduledRuns: string
|
|
23
|
+
version: 3
|
|
24
|
+
database: {
|
|
25
|
+
dialect: Dialect
|
|
26
|
+
migrationsDirectory: string
|
|
27
|
+
tables: { exportName: string; physicalName: string; system: boolean }[]
|
|
29
28
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
storage: {
|
|
30
|
+
defaultBucket: string
|
|
31
|
+
buckets: { name: string; visibility: ResolvedBucket['visibility'] }[]
|
|
32
|
+
}
|
|
33
|
+
realtime: { required: boolean }
|
|
34
|
+
environment: ManifestEnvVar[]
|
|
35
35
|
background: {
|
|
36
36
|
jobs: { name: string }[]
|
|
37
37
|
cron: { name: string; schedule: string; timezone: 'UTC' }[]
|
|
38
|
-
maintenance: {
|
|
38
|
+
maintenance: {
|
|
39
|
+
name: 'storage-sweep'
|
|
40
|
+
schedule: string
|
|
41
|
+
timezone: 'UTC'
|
|
42
|
+
}[]
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
const nonEmpty = z.string().min(1)
|
|
47
|
+
const migrationDirectory = nonEmpty.refine(
|
|
48
|
+
(value) =>
|
|
49
|
+
value.startsWith('/') ||
|
|
50
|
+
(!value.includes('\\') && value.split('/').every((part) => part !== '..' && part !== '')),
|
|
51
|
+
{ message: 'migrationsDirectory must be an absolute path or a relative path without traversal' },
|
|
52
|
+
)
|
|
53
|
+
const cronSchedule = nonEmpty.refine(
|
|
54
|
+
(value) => {
|
|
55
|
+
try {
|
|
56
|
+
parseCron(value)
|
|
57
|
+
return true
|
|
58
|
+
} catch {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{ message: 'invalid cron schedule' },
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const manifestSchema = z
|
|
66
|
+
.object({
|
|
67
|
+
version: z.literal(3),
|
|
68
|
+
database: z
|
|
69
|
+
.object({
|
|
70
|
+
dialect: z.enum(['sqlite', 'pg']),
|
|
71
|
+
migrationsDirectory: migrationDirectory,
|
|
72
|
+
tables: z.array(
|
|
73
|
+
z
|
|
74
|
+
.object({
|
|
75
|
+
exportName: nonEmpty,
|
|
76
|
+
physicalName: nonEmpty,
|
|
77
|
+
system: z.boolean(),
|
|
78
|
+
})
|
|
79
|
+
.strict(),
|
|
80
|
+
),
|
|
81
|
+
})
|
|
82
|
+
.strict(),
|
|
83
|
+
storage: z
|
|
84
|
+
.object({
|
|
85
|
+
defaultBucket: nonEmpty,
|
|
86
|
+
buckets: z.array(
|
|
87
|
+
z
|
|
88
|
+
.object({ name: nonEmpty, visibility: z.enum(['public', 'private']) })
|
|
89
|
+
.strict(),
|
|
90
|
+
),
|
|
91
|
+
})
|
|
92
|
+
.strict(),
|
|
93
|
+
realtime: z.object({ required: z.boolean() }).strict(),
|
|
94
|
+
environment: z.array(
|
|
95
|
+
z
|
|
96
|
+
.object({
|
|
97
|
+
key: nonEmpty,
|
|
98
|
+
required: z.boolean(),
|
|
99
|
+
scope: z.enum(['server', 'client']),
|
|
100
|
+
})
|
|
101
|
+
.strict(),
|
|
46
102
|
),
|
|
103
|
+
background: z
|
|
104
|
+
.object({
|
|
105
|
+
jobs: z.array(z.object({ name: nonEmpty }).strict()),
|
|
106
|
+
cron: z.array(
|
|
107
|
+
z
|
|
108
|
+
.object({
|
|
109
|
+
name: nonEmpty,
|
|
110
|
+
schedule: cronSchedule,
|
|
111
|
+
timezone: z.literal('UTC'),
|
|
112
|
+
})
|
|
113
|
+
.strict(),
|
|
114
|
+
),
|
|
115
|
+
maintenance: z.array(
|
|
116
|
+
z
|
|
117
|
+
.object({
|
|
118
|
+
name: z.literal('storage-sweep'),
|
|
119
|
+
schedule: cronSchedule,
|
|
120
|
+
timezone: z.literal('UTC'),
|
|
121
|
+
})
|
|
122
|
+
.strict(),
|
|
123
|
+
),
|
|
124
|
+
})
|
|
125
|
+
.strict(),
|
|
126
|
+
})
|
|
127
|
+
.strict()
|
|
128
|
+
|
|
129
|
+
function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
|
|
130
|
+
return [...entries].sort((left, right) => key(left).localeCompare(key(right)))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function rejectDuplicates(collection: string, values: readonly string[]): void {
|
|
134
|
+
const seen = new Set<string>()
|
|
135
|
+
for (const value of values) {
|
|
136
|
+
if (seen.has(value)) throw new Error(`[bunderstack] duplicate ${collection} "${value}"`)
|
|
137
|
+
seen.add(value)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function describeTables(schema: Record<string, unknown>) {
|
|
142
|
+
const systemNames = new Set<string>(systemTables().map((table) => table.physicalName))
|
|
143
|
+
return sortBy(
|
|
144
|
+
Object.entries(schema).flatMap(([exportName, value]) =>
|
|
145
|
+
isTable(value) && !systemNames.has(getTableName(value))
|
|
146
|
+
? [{ exportName, physicalName: getTableName(value), system: false }]
|
|
147
|
+
: [],
|
|
148
|
+
),
|
|
149
|
+
(entry) => entry.physicalName,
|
|
47
150
|
)
|
|
48
151
|
}
|
|
49
152
|
|
|
50
153
|
function describeSection(
|
|
51
154
|
section: Record<string, ZodType> | undefined,
|
|
155
|
+
scope: ManifestEnvVar['scope'],
|
|
52
156
|
): ManifestEnvVar[] {
|
|
53
157
|
return Object.entries(section ?? {}).map(([key, schema]) => ({
|
|
54
158
|
key,
|
|
55
159
|
required: !schema.safeParse(undefined).success,
|
|
160
|
+
scope,
|
|
56
161
|
}))
|
|
57
162
|
}
|
|
58
163
|
|
|
164
|
+
function systemTables() {
|
|
165
|
+
return [
|
|
166
|
+
{ exportName: '_system.files', physicalName: getTableName(bunderstackFiles), system: true },
|
|
167
|
+
{
|
|
168
|
+
exportName: '_system.idempotency',
|
|
169
|
+
physicalName: getTableName(bunderstackIdempotency),
|
|
170
|
+
system: true,
|
|
171
|
+
},
|
|
172
|
+
{ exportName: '_system.jobs', physicalName: getTableName(bunderstackJobs), system: true },
|
|
173
|
+
{
|
|
174
|
+
exportName: '_system.scheduledRuns',
|
|
175
|
+
physicalName: getTableName(bunderstackCronRuns),
|
|
176
|
+
system: true,
|
|
177
|
+
},
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function parseManifest(value: unknown): BunderstackManifest {
|
|
182
|
+
const manifest = manifestSchema.parse(value) as BunderstackManifest
|
|
183
|
+
rejectDuplicates('database physical table', manifest.database.tables.map((entry) => entry.physicalName))
|
|
184
|
+
rejectDuplicates('database export table', manifest.database.tables.map((entry) => entry.exportName))
|
|
185
|
+
rejectDuplicates('storage bucket', manifest.storage.buckets.map((entry) => entry.name))
|
|
186
|
+
rejectDuplicates('environment key', manifest.environment.map((entry) => entry.key))
|
|
187
|
+
rejectDuplicates('background job', manifest.background.jobs.map((entry) => entry.name))
|
|
188
|
+
rejectDuplicates('background cron', manifest.background.cron.map((entry) => entry.name))
|
|
189
|
+
rejectDuplicates('background maintenance', manifest.background.maintenance.map((entry) => entry.name))
|
|
190
|
+
return manifest
|
|
191
|
+
}
|
|
192
|
+
|
|
59
193
|
export function buildManifest(args: {
|
|
60
194
|
schema: Record<string, unknown>
|
|
61
195
|
dialect: Dialect
|
|
196
|
+
migrationsDirectory: string
|
|
62
197
|
storage: ResolvedStorageBuckets
|
|
63
198
|
envConfig: EnvConfigInput | undefined
|
|
199
|
+
emailProvider: string | undefined
|
|
64
200
|
realtime: boolean
|
|
65
|
-
realtimeTransport: RealtimeTransport
|
|
66
201
|
jobs: JobsDefs | undefined
|
|
67
202
|
}): BunderstackManifest {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
203
|
+
const environment = [
|
|
204
|
+
...describeSection(args.envConfig?.server, 'server'),
|
|
205
|
+
...describeSection(args.envConfig?.client, 'client'),
|
|
206
|
+
...(args.emailProvider === 'resend'
|
|
207
|
+
? [{ key: 'RESEND_API_KEY', required: true, scope: 'server' as const }]
|
|
208
|
+
: []),
|
|
209
|
+
...(args.emailProvider === 'smtp'
|
|
210
|
+
? [{ key: 'SMTP_URL', required: true, scope: 'server' as const }]
|
|
211
|
+
: []),
|
|
212
|
+
]
|
|
213
|
+
rejectDuplicates('environment key', environment.map((entry) => entry.key))
|
|
214
|
+
|
|
215
|
+
return parseManifest({
|
|
216
|
+
version: 3,
|
|
217
|
+
database: {
|
|
218
|
+
dialect: args.dialect,
|
|
219
|
+
migrationsDirectory: args.migrationsDirectory,
|
|
220
|
+
tables: sortBy([...systemTables(), ...describeTables(args.schema)], (entry) => entry.physicalName),
|
|
77
221
|
},
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
client: describeSection(args.envConfig?.client),
|
|
222
|
+
storage: {
|
|
223
|
+
defaultBucket: args.storage.defaultBucket,
|
|
224
|
+
buckets: sortBy(
|
|
225
|
+
[...args.storage.buckets.values()].map((bucket) => ({
|
|
226
|
+
name: bucket.name,
|
|
227
|
+
visibility: bucket.visibility,
|
|
228
|
+
})),
|
|
229
|
+
(bucket) => bucket.name,
|
|
230
|
+
),
|
|
88
231
|
},
|
|
232
|
+
realtime: { required: args.realtime },
|
|
233
|
+
environment: sortBy(environment, (entry) => entry.key),
|
|
89
234
|
background: {
|
|
90
|
-
jobs:
|
|
91
|
-
.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
235
|
+
jobs: sortBy(
|
|
236
|
+
Object.entries(args.jobs ?? {})
|
|
237
|
+
.filter(([, def]) => def.kind === 'job')
|
|
238
|
+
.map(([name]) => ({ name })),
|
|
239
|
+
(entry) => entry.name,
|
|
240
|
+
),
|
|
241
|
+
cron: sortBy(
|
|
242
|
+
Object.entries(args.jobs ?? {})
|
|
243
|
+
.filter(([, def]) => def.kind === 'cron')
|
|
244
|
+
.map(([name, def]) => ({
|
|
245
|
+
name,
|
|
246
|
+
schedule: def.kind === 'cron' ? def.schedule : '',
|
|
247
|
+
timezone: 'UTC' as const,
|
|
248
|
+
})),
|
|
249
|
+
(entry) => entry.name,
|
|
250
|
+
),
|
|
251
|
+
maintenance: [
|
|
252
|
+
{ name: 'storage-sweep', schedule: '0 4 * * *', timezone: 'UTC' as const },
|
|
253
|
+
],
|
|
101
254
|
},
|
|
102
|
-
}
|
|
255
|
+
})
|
|
103
256
|
}
|
package/src/provision.ts
CHANGED
|
@@ -105,6 +105,8 @@ export async function provision(
|
|
|
105
105
|
app: object,
|
|
106
106
|
options?: { force?: boolean },
|
|
107
107
|
): Promise<void> {
|
|
108
|
+
if (process.env.BUNDERSTACK_INTROSPECT === '1') return
|
|
109
|
+
|
|
108
110
|
const internals = (app as WithProvisionInternals)[PROVISION_INTERNALS]
|
|
109
111
|
if (!internals) {
|
|
110
112
|
throw new Error(
|
package/src/realtime/redis.ts
CHANGED
|
@@ -125,7 +125,8 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
125
125
|
if (typeof entry.get === 'function') return false
|
|
126
126
|
if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
|
|
127
127
|
return false
|
|
128
|
-
if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx)))
|
|
128
|
+
if (entry.readScope && !rowMatchesScope(record, entry.readScope(ctx)))
|
|
129
|
+
return false
|
|
129
130
|
return true
|
|
130
131
|
}
|
|
131
132
|
|
|
@@ -140,7 +141,10 @@ export function createRedisRealtimeBroker(opts: {
|
|
|
140
141
|
let closed = false
|
|
141
142
|
|
|
142
143
|
const start = () => {
|
|
143
|
-
if (closed)
|
|
144
|
+
if (closed)
|
|
145
|
+
return Promise.reject(
|
|
146
|
+
new Error('[bunderstack] realtime broker is closed'),
|
|
147
|
+
)
|
|
144
148
|
started ??= getRedis()
|
|
145
149
|
.subscribe(channel, (message) => {
|
|
146
150
|
const evt = parseWireEvent(message)
|