bunderstack 0.16.0 → 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 +21 -14
- package/src/access.ts +6 -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 +57 -39
- package/src/crud-operations.ts +488 -0
- package/src/dialect.ts +1 -1
- package/src/env.ts +19 -13
- package/src/errors.ts +90 -23
- package/src/handler.ts +16 -44
- package/src/index.ts +235 -246
- package/src/jobs/define.ts +8 -10
- package/src/jobs/queue.ts +6 -2
- package/src/jobs/worker.ts +4 -1
- package/src/manifest.ts +84 -87
- 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 -400
- package/src/realtime/index.ts +0 -242
- package/src/realtime/redis.ts +0 -219
- package/src/routes.ts +0 -137
- 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,
|
|
@@ -23,34 +25,35 @@ export type BetterAuthConfig = Omit<
|
|
|
23
25
|
// Only the union-shaped options need runtime validation: they are the ones a
|
|
24
26
|
// JavaScript caller can plausibly get wrong in a way that fails confusingly
|
|
25
27
|
// downstream. Everything else is either typed-only or read raw from `options`.
|
|
26
|
-
const RuntimeOptionsSchema =
|
|
27
|
-
rateLimit:
|
|
28
|
-
.union([
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
windowMs:
|
|
32
|
-
max:
|
|
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()),
|
|
33
35
|
}),
|
|
34
|
-
])
|
|
35
|
-
|
|
36
|
-
idempotency:
|
|
37
|
-
.union([
|
|
38
|
-
|
|
39
|
-
realtime:
|
|
40
|
-
.union([
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
redis:
|
|
46
|
-
.union([
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
])
|
|
50
|
-
|
|
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
|
+
),
|
|
51
53
|
}),
|
|
52
|
-
])
|
|
53
|
-
|
|
54
|
+
]),
|
|
55
|
+
),
|
|
56
|
+
openapi: v.optional(v.boolean()),
|
|
54
57
|
})
|
|
55
58
|
|
|
56
59
|
export type BunderstackConfig<
|
|
@@ -62,6 +65,7 @@ export type BunderstackConfig<
|
|
|
62
65
|
| StorageConfigInput
|
|
63
66
|
| undefined,
|
|
64
67
|
TEnv extends EnvConfigInput | undefined = EnvConfigInput | undefined,
|
|
68
|
+
TCustomApiRouter extends AnyRouter | undefined = AnyRouter | undefined,
|
|
65
69
|
> = {
|
|
66
70
|
schema: TSchema
|
|
67
71
|
access?: TAccess
|
|
@@ -73,8 +77,8 @@ export type BunderstackConfig<
|
|
|
73
77
|
}
|
|
74
78
|
auth?: BetterAuthConfig
|
|
75
79
|
/**
|
|
76
|
-
* Reuse an application-owned session reader for
|
|
77
|
-
*
|
|
80
|
+
* Reuse an application-owned session reader for the unified API while
|
|
81
|
+
* keeping Bunderstack's auth handler available.
|
|
78
82
|
*/
|
|
79
83
|
authResolver?: AuthSessionResolver
|
|
80
84
|
storage?: TStorage
|
|
@@ -88,18 +92,20 @@ export type BunderstackConfig<
|
|
|
88
92
|
background?: { autoStart?: boolean }
|
|
89
93
|
email?: EmailConfigInput
|
|
90
94
|
/**
|
|
91
|
-
*
|
|
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.
|
|
95
|
+
* Unified oRPC API builder callback.
|
|
94
96
|
*/
|
|
95
|
-
|
|
97
|
+
api?: (
|
|
98
|
+
builder: BunderstackApiBuilder<TSchema, ValidatedEnv<TEnv>>,
|
|
99
|
+
) => TCustomApiRouter
|
|
96
100
|
rateLimit?: boolean | RateLimitConfig
|
|
97
101
|
idempotency?: boolean | IdempotencyConfig
|
|
102
|
+
/** Generate and serve `/api/openapi.json`. Disabled by default. */
|
|
103
|
+
openapi?: boolean
|
|
98
104
|
realtime?:
|
|
99
105
|
| boolean
|
|
100
106
|
| {
|
|
101
|
-
keepaliveMs?: number
|
|
102
107
|
bufferSize?: number
|
|
108
|
+
resumeSeconds?: number
|
|
103
109
|
redis?: string | { url: string; token?: string }
|
|
104
110
|
}
|
|
105
111
|
}
|
|
@@ -116,14 +122,26 @@ export type ResolvedConfig = {
|
|
|
116
122
|
realtime?:
|
|
117
123
|
| boolean
|
|
118
124
|
| {
|
|
119
|
-
keepaliveMs?: number
|
|
120
125
|
bufferSize?: number
|
|
126
|
+
resumeSeconds?: number
|
|
121
127
|
redis?: string | { url: string; token?: string }
|
|
122
128
|
}
|
|
123
129
|
}
|
|
124
130
|
|
|
125
|
-
export function resolveConfig<
|
|
126
|
-
|
|
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
|
+
>,
|
|
127
145
|
env?: BaseEnv,
|
|
128
146
|
// Platform-injected overrides (Bunderhost & co.) beat code-level config so
|
|
129
147
|
// apps with hardcoded local urls deploy unchanged.
|
|
@@ -132,7 +150,7 @@ export function resolveConfig<TSchema extends Record<string, unknown>>(
|
|
|
132
150
|
string | undefined
|
|
133
151
|
>,
|
|
134
152
|
): ResolvedConfig {
|
|
135
|
-
const parsed =
|
|
153
|
+
const parsed = v.parse(RuntimeOptionsSchema, options)
|
|
136
154
|
// Self-validate when the caller didn't pass a pre-validated env, so
|
|
137
155
|
// resolveConfig stays usable standalone.
|
|
138
156
|
const resolvedEnv =
|