bunderstack 0.15.2 → 0.17.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -138
- package/package.json +22 -14
- package/src/access.ts +24 -1
- package/src/api/api-types.types.ts +106 -0
- package/src/api/builder.ts +52 -0
- package/src/api/context.ts +83 -0
- package/src/api/crud-router.ts +321 -0
- package/src/api/openapi.ts +184 -0
- package/src/api/realtime-router.ts +75 -0
- package/src/api/registry.ts +338 -0
- package/src/api/router.ts +34 -0
- package/src/api/storage-router.ts +224 -0
- package/src/api/types.ts +84 -0
- package/src/auth.ts +5 -0
- package/src/blueprint.ts +88 -105
- package/src/config.ts +73 -77
- package/src/cron.ts +2 -1
- package/src/crud-operations.ts +488 -0
- package/src/dialect.ts +1 -1
- package/src/env.ts +28 -21
- package/src/errors.ts +90 -23
- package/src/handler.ts +16 -44
- package/src/index.ts +283 -294
- package/src/internal-tables-pg.ts +1 -17
- package/src/internal-tables.ts +0 -31
- package/src/jobs/define.ts +75 -21
- package/src/jobs/index.ts +3 -9
- package/src/jobs/queue.ts +14 -6
- package/src/jobs/slots.ts +52 -0
- package/src/jobs/worker.ts +142 -42
- package/src/manifest.ts +84 -93
- package/src/realtime/facade.ts +16 -13
- package/src/realtime/filter.ts +77 -0
- package/src/realtime/heartbeat.ts +80 -0
- package/src/realtime/publisher.ts +46 -0
- package/src/standard-schema.ts +59 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/operations.ts +398 -0
- package/src/crud.ts +0 -408
- package/src/jobs/cron-auth.ts +0 -28
- package/src/jobs/cron-router.ts +0 -135
- package/src/jobs/cron-runner.ts +0 -224
- package/src/jobs/local-cron.ts +0 -78
- package/src/realtime/index.ts +0 -250
- package/src/realtime/redis.ts +0 -228
- package/src/storage/router.ts +0 -531
- package/src/trpc.ts +0 -57
package/src/blueprint.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
1
2
|
import { parse, stringify } from 'yaml'
|
|
2
|
-
import { z } from 'zod'
|
|
3
3
|
|
|
4
4
|
import type { BunderstackManifest } from './manifest'
|
|
5
5
|
|
|
6
6
|
import { parseCron } from './jobs/cron'
|
|
7
|
+
import { validateStandardSchema } from './standard-schema'
|
|
7
8
|
|
|
8
9
|
export type MigrationMode = 'migrations' | 'push'
|
|
9
10
|
|
|
@@ -26,119 +27,97 @@ export type BunderstackBlueprint = {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
const nonEmpty =
|
|
30
|
-
const relativePath =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
const nonEmpty = v.pipe(v.string(), v.minLength(1))
|
|
31
|
+
const relativePath = v.pipe(
|
|
32
|
+
nonEmpty,
|
|
33
|
+
v.check(
|
|
34
|
+
(value) =>
|
|
35
|
+
!value.startsWith('/') &&
|
|
36
|
+
!value.includes('\\') &&
|
|
37
|
+
value.split('/').every((part) => part !== '' && part !== '..'),
|
|
38
|
+
'entry must be a relative path without traversal',
|
|
39
|
+
),
|
|
36
40
|
)
|
|
37
|
-
const cronSchedule =
|
|
38
|
-
|
|
41
|
+
const cronSchedule = v.pipe(
|
|
42
|
+
nonEmpty,
|
|
43
|
+
v.check((value) => {
|
|
39
44
|
try {
|
|
40
45
|
parseCron(value)
|
|
41
46
|
return true
|
|
42
47
|
} catch {
|
|
43
48
|
return false
|
|
44
49
|
}
|
|
45
|
-
},
|
|
46
|
-
{ message: 'invalid cron schedule' },
|
|
50
|
+
}, 'invalid cron schedule'),
|
|
47
51
|
)
|
|
48
52
|
|
|
49
|
-
const blueprintSchema =
|
|
50
|
-
.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
.
|
|
70
|
-
|
|
71
|
-
.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
environment: z.array(
|
|
108
|
-
z
|
|
109
|
-
.object({
|
|
110
|
-
key: nonEmpty,
|
|
111
|
-
required: z.boolean(),
|
|
112
|
-
scope: z.enum(['server', 'client']),
|
|
113
|
-
})
|
|
114
|
-
.strict(),
|
|
53
|
+
const blueprintSchema = v.strictObject({
|
|
54
|
+
version: v.literal(1),
|
|
55
|
+
generator: v.strictObject({
|
|
56
|
+
name: v.literal('bunderstack'),
|
|
57
|
+
version: nonEmpty,
|
|
58
|
+
}),
|
|
59
|
+
application: v.strictObject({
|
|
60
|
+
framework: v.literal('tanstack-start'),
|
|
61
|
+
scripts: v.strictObject({
|
|
62
|
+
build: v.literal('build'),
|
|
63
|
+
start: v.literal('start'),
|
|
64
|
+
worker: v.optional(v.literal('worker')),
|
|
65
|
+
}),
|
|
66
|
+
}),
|
|
67
|
+
bunderstack: v.strictObject({
|
|
68
|
+
entry: relativePath,
|
|
69
|
+
manifestVersion: v.literal(3),
|
|
70
|
+
}),
|
|
71
|
+
resources: v.strictObject({
|
|
72
|
+
database: v.strictObject({
|
|
73
|
+
dialect: v.picklist(['sqlite', 'pg']),
|
|
74
|
+
migrationsDirectory: relativePath,
|
|
75
|
+
migrationMode: v.picklist(['migrations', 'push']),
|
|
76
|
+
tables: v.array(
|
|
77
|
+
v.strictObject({
|
|
78
|
+
exportName: nonEmpty,
|
|
79
|
+
physicalName: nonEmpty,
|
|
80
|
+
system: v.boolean(),
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
}),
|
|
84
|
+
storage: v.strictObject({
|
|
85
|
+
defaultBucket: nonEmpty,
|
|
86
|
+
buckets: v.array(
|
|
87
|
+
v.strictObject({
|
|
88
|
+
name: nonEmpty,
|
|
89
|
+
visibility: v.picklist(['public', 'private']),
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
}),
|
|
93
|
+
realtime: v.optional(v.strictObject({ required: v.literal(true) })),
|
|
94
|
+
}),
|
|
95
|
+
environment: v.array(
|
|
96
|
+
v.strictObject({
|
|
97
|
+
key: nonEmpty,
|
|
98
|
+
required: v.boolean(),
|
|
99
|
+
scope: v.picklist(['server', 'client']),
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
background: v.strictObject({
|
|
103
|
+
worker: v.strictObject({ required: v.boolean() }),
|
|
104
|
+
jobs: v.array(v.strictObject({ name: nonEmpty })),
|
|
105
|
+
cron: v.array(
|
|
106
|
+
v.strictObject({
|
|
107
|
+
name: nonEmpty,
|
|
108
|
+
schedule: cronSchedule,
|
|
109
|
+
timezone: v.literal('UTC'),
|
|
110
|
+
}),
|
|
115
111
|
),
|
|
116
|
-
|
|
117
|
-
.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
timezone: z.literal('UTC'),
|
|
126
|
-
})
|
|
127
|
-
.strict(),
|
|
128
|
-
),
|
|
129
|
-
maintenance: z.array(
|
|
130
|
-
z
|
|
131
|
-
.object({
|
|
132
|
-
name: z.literal('storage-sweep'),
|
|
133
|
-
schedule: cronSchedule,
|
|
134
|
-
timezone: z.literal('UTC'),
|
|
135
|
-
})
|
|
136
|
-
.strict(),
|
|
137
|
-
),
|
|
138
|
-
})
|
|
139
|
-
.strict(),
|
|
140
|
-
})
|
|
141
|
-
.strict()
|
|
112
|
+
maintenance: v.array(
|
|
113
|
+
v.strictObject({
|
|
114
|
+
name: v.literal('storage-sweep'),
|
|
115
|
+
schedule: cronSchedule,
|
|
116
|
+
timezone: v.literal('UTC'),
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
}),
|
|
120
|
+
})
|
|
142
121
|
|
|
143
122
|
function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
|
|
144
123
|
return [...entries].sort((left, right) => key(left).localeCompare(key(right)))
|
|
@@ -154,7 +133,11 @@ function rejectDuplicates(collection: string, values: readonly string[]): void {
|
|
|
154
133
|
}
|
|
155
134
|
|
|
156
135
|
export function parseBlueprint(value: unknown): BunderstackBlueprint {
|
|
157
|
-
const blueprint =
|
|
136
|
+
const blueprint = validateStandardSchema(
|
|
137
|
+
blueprintSchema,
|
|
138
|
+
value,
|
|
139
|
+
'blueprint',
|
|
140
|
+
) as BunderstackBlueprint
|
|
158
141
|
rejectDuplicates(
|
|
159
142
|
'database physical table',
|
|
160
143
|
blueprint.resources.database.tables.map((entry) => entry.physicalName),
|
package/src/config.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// src/config.ts
|
|
2
2
|
import { betterAuth } from 'better-auth'
|
|
3
|
-
import
|
|
3
|
+
import * as v from 'valibot'
|
|
4
4
|
|
|
5
|
+
import type { AnyRouter } from '@orpc/server'
|
|
5
6
|
import type { AuthSessionResolver, TableAccessInput } from './access'
|
|
7
|
+
import type { BunderstackApiBuilder } from './api/builder'
|
|
6
8
|
import type { DatabaseAdapter } from './database/adapter'
|
|
7
9
|
import type { EmailConfigInput } from './email'
|
|
8
10
|
import type { IdempotencyConfig } from './idempotency'
|
|
9
11
|
import type { RateLimitConfig } from './rate-limit'
|
|
10
12
|
|
|
11
|
-
import { validateEnv, type BaseEnv, type EnvConfigInput } from './env'
|
|
13
|
+
import { validateEnv, type BaseEnv, type EnvConfigInput, type ValidatedEnv } from './env'
|
|
12
14
|
import {
|
|
13
15
|
resolveBuckets,
|
|
14
16
|
type ResolvedStorageBuckets,
|
|
@@ -20,56 +22,38 @@ export type BetterAuthConfig = Omit<
|
|
|
20
22
|
'database'
|
|
21
23
|
>
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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(),
|
|
46
|
-
rateLimit: z
|
|
47
|
-
.union([
|
|
48
|
-
z.boolean(),
|
|
49
|
-
z.object({
|
|
50
|
-
windowMs: z.number().optional(),
|
|
51
|
-
max: z.number().optional(),
|
|
25
|
+
// Only the union-shaped options need runtime validation: they are the ones a
|
|
26
|
+
// JavaScript caller can plausibly get wrong in a way that fails confusingly
|
|
27
|
+
// downstream. Everything else is either typed-only or read raw from `options`.
|
|
28
|
+
const RuntimeOptionsSchema = v.object({
|
|
29
|
+
rateLimit: v.optional(
|
|
30
|
+
v.union([
|
|
31
|
+
v.boolean(),
|
|
32
|
+
v.object({
|
|
33
|
+
windowMs: v.optional(v.number()),
|
|
34
|
+
max: v.optional(v.number()),
|
|
52
35
|
}),
|
|
53
|
-
])
|
|
54
|
-
|
|
55
|
-
idempotency:
|
|
56
|
-
.union([
|
|
57
|
-
|
|
58
|
-
realtime:
|
|
59
|
-
.union([
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
redis:
|
|
65
|
-
.union([
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
])
|
|
69
|
-
|
|
36
|
+
]),
|
|
37
|
+
),
|
|
38
|
+
idempotency: v.optional(
|
|
39
|
+
v.union([v.boolean(), v.object({ ttlMs: v.optional(v.number()) })]),
|
|
40
|
+
),
|
|
41
|
+
realtime: v.optional(
|
|
42
|
+
v.union([
|
|
43
|
+
v.boolean(),
|
|
44
|
+
v.object({
|
|
45
|
+
bufferSize: v.optional(v.number()),
|
|
46
|
+
resumeSeconds: v.optional(v.number()),
|
|
47
|
+
redis: v.optional(
|
|
48
|
+
v.union([
|
|
49
|
+
v.string(),
|
|
50
|
+
v.object({ url: v.string(), token: v.optional(v.string()) }),
|
|
51
|
+
]),
|
|
52
|
+
),
|
|
70
53
|
}),
|
|
71
|
-
])
|
|
72
|
-
|
|
54
|
+
]),
|
|
55
|
+
),
|
|
56
|
+
openapi: v.optional(v.boolean()),
|
|
73
57
|
})
|
|
74
58
|
|
|
75
59
|
export type BunderstackConfig<
|
|
@@ -81,19 +65,8 @@ export type BunderstackConfig<
|
|
|
81
65
|
| StorageConfigInput
|
|
82
66
|
| undefined,
|
|
83
67
|
TEnv extends EnvConfigInput | undefined = EnvConfigInput | undefined,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
| 'schema'
|
|
87
|
-
| 'access'
|
|
88
|
-
| 'auth'
|
|
89
|
-
| 'authResolver'
|
|
90
|
-
| 'storage'
|
|
91
|
-
| 'env'
|
|
92
|
-
| 'email'
|
|
93
|
-
| 'trpc'
|
|
94
|
-
| 'jobs'
|
|
95
|
-
| 'database'
|
|
96
|
-
> & {
|
|
68
|
+
TCustomApiRouter extends AnyRouter | undefined = AnyRouter | undefined,
|
|
69
|
+
> = {
|
|
97
70
|
schema: TSchema
|
|
98
71
|
access?: TAccess
|
|
99
72
|
database: {
|
|
@@ -104,24 +77,35 @@ export type BunderstackConfig<
|
|
|
104
77
|
}
|
|
105
78
|
auth?: BetterAuthConfig
|
|
106
79
|
/**
|
|
107
|
-
* Reuse an application-owned session reader for
|
|
108
|
-
*
|
|
80
|
+
* Reuse an application-owned session reader for the unified API while
|
|
81
|
+
* keeping Bunderstack's auth handler available.
|
|
109
82
|
*/
|
|
110
83
|
authResolver?: AuthSessionResolver
|
|
111
84
|
storage?: TStorage
|
|
112
85
|
env?: TEnv
|
|
86
|
+
/**
|
|
87
|
+
* Stand-in for `process.env`. Feeds both env validation and platform
|
|
88
|
+
* overrides, so tests and embedders have one injection point instead of
|
|
89
|
+
* three.
|
|
90
|
+
*/
|
|
91
|
+
processEnv?: Record<string, string | undefined>
|
|
92
|
+
background?: { autoStart?: boolean }
|
|
113
93
|
email?: EmailConfigInput
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Unified oRPC API builder callback.
|
|
96
|
+
*/
|
|
97
|
+
api?: (
|
|
98
|
+
builder: BunderstackApiBuilder<TSchema, ValidatedEnv<TEnv>>,
|
|
99
|
+
) => TCustomApiRouter
|
|
118
100
|
rateLimit?: boolean | RateLimitConfig
|
|
119
101
|
idempotency?: boolean | IdempotencyConfig
|
|
102
|
+
/** Generate and serve `/api/openapi.json`. Disabled by default. */
|
|
103
|
+
openapi?: boolean
|
|
120
104
|
realtime?:
|
|
121
105
|
| boolean
|
|
122
106
|
| {
|
|
123
|
-
keepaliveMs?: number
|
|
124
107
|
bufferSize?: number
|
|
108
|
+
resumeSeconds?: number
|
|
125
109
|
redis?: string | { url: string; token?: string }
|
|
126
110
|
}
|
|
127
111
|
}
|
|
@@ -138,14 +122,26 @@ export type ResolvedConfig = {
|
|
|
138
122
|
realtime?:
|
|
139
123
|
| boolean
|
|
140
124
|
| {
|
|
141
|
-
keepaliveMs?: number
|
|
142
125
|
bufferSize?: number
|
|
126
|
+
resumeSeconds?: number
|
|
143
127
|
redis?: string | { url: string; token?: string }
|
|
144
128
|
}
|
|
145
129
|
}
|
|
146
130
|
|
|
147
|
-
export function resolveConfig<
|
|
148
|
-
|
|
131
|
+
export function resolveConfig<
|
|
132
|
+
TSchema extends Record<string, unknown>,
|
|
133
|
+
TAccess extends Record<string, TableAccessInput> | undefined = undefined,
|
|
134
|
+
TStorage extends StorageConfigInput | undefined = undefined,
|
|
135
|
+
TEnv extends EnvConfigInput | undefined = undefined,
|
|
136
|
+
TCustomApiRouter extends AnyRouter | undefined = undefined,
|
|
137
|
+
>(
|
|
138
|
+
options: BunderstackConfig<
|
|
139
|
+
TSchema,
|
|
140
|
+
TAccess,
|
|
141
|
+
TStorage,
|
|
142
|
+
TEnv,
|
|
143
|
+
TCustomApiRouter
|
|
144
|
+
>,
|
|
149
145
|
env?: BaseEnv,
|
|
150
146
|
// Platform-injected overrides (Bunderhost & co.) beat code-level config so
|
|
151
147
|
// apps with hardcoded local urls deploy unchanged.
|
|
@@ -154,7 +150,7 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
154
150
|
string | undefined
|
|
155
151
|
>,
|
|
156
152
|
): ResolvedConfig {
|
|
157
|
-
const parsed =
|
|
153
|
+
const parsed = v.parse(RuntimeOptionsSchema, options)
|
|
158
154
|
// Self-validate when the caller didn't pass a pre-validated env, so
|
|
159
155
|
// resolveConfig stays usable standalone.
|
|
160
156
|
const resolvedEnv =
|
|
@@ -173,14 +169,14 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
173
169
|
adapter,
|
|
174
170
|
url:
|
|
175
171
|
platformSource['BUNDERSTACK_DATABASE_URL'] ??
|
|
176
|
-
|
|
172
|
+
options.database?.url ??
|
|
177
173
|
resolvedEnv.DATABASE_URL ??
|
|
178
174
|
defaultUrl,
|
|
179
175
|
authToken:
|
|
180
176
|
platformSource['BUNDERSTACK_DATABASE_AUTH_TOKEN'] ??
|
|
181
|
-
|
|
177
|
+
options.database?.authToken ??
|
|
182
178
|
resolvedEnv.DATABASE_AUTH_TOKEN,
|
|
183
|
-
migrations:
|
|
179
|
+
migrations: options.database?.migrations ?? './migrations',
|
|
184
180
|
},
|
|
185
181
|
auth: (() => {
|
|
186
182
|
const authInput = options.auth ?? {}
|
package/src/cron.ts
CHANGED