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/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 = z.string().min(1)
30
- const relativePath = nonEmpty.refine(
31
- (value) =>
32
- !value.startsWith('/') &&
33
- !value.includes('\\') &&
34
- value.split('/').every((part) => part !== '' && part !== '..'),
35
- { message: 'entry must be a relative path without traversal' },
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 = nonEmpty.refine(
38
- (value) => {
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 = z
50
- .object({
51
- version: z.literal(1),
52
- generator: z
53
- .object({ name: z.literal('bunderstack'), version: nonEmpty })
54
- .strict(),
55
- application: z
56
- .object({
57
- framework: z.literal('tanstack-start'),
58
- scripts: z
59
- .object({
60
- build: z.literal('build'),
61
- start: z.literal('start'),
62
- worker: z.literal('worker').optional(),
63
- })
64
- .strict(),
65
- })
66
- .strict(),
67
- bunderstack: z
68
- .object({ entry: relativePath, manifestVersion: z.literal(3) })
69
- .strict(),
70
- resources: z
71
- .object({
72
- database: z
73
- .object({
74
- dialect: z.enum(['sqlite', 'pg']),
75
- migrationsDirectory: relativePath,
76
- migrationMode: z.enum(['migrations', 'push']),
77
- tables: z.array(
78
- z
79
- .object({
80
- exportName: nonEmpty,
81
- physicalName: nonEmpty,
82
- system: z.boolean(),
83
- })
84
- .strict(),
85
- ),
86
- })
87
- .strict(),
88
- storage: z
89
- .object({
90
- defaultBucket: nonEmpty,
91
- buckets: z.array(
92
- z
93
- .object({
94
- name: nonEmpty,
95
- visibility: z.enum(['public', 'private']),
96
- })
97
- .strict(),
98
- ),
99
- })
100
- .strict(),
101
- realtime: z
102
- .object({ required: z.literal(true) })
103
- .strict()
104
- .optional(),
105
- })
106
- .strict(),
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
- background: z
117
- .object({
118
- worker: z.object({ required: z.boolean() }).strict(),
119
- jobs: z.array(z.object({ name: nonEmpty }).strict()),
120
- cron: z.array(
121
- z
122
- .object({
123
- name: nonEmpty,
124
- schedule: cronSchedule,
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 = blueprintSchema.parse(value) as BunderstackBlueprint
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 { z } from 'zod'
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 = z.object({
27
- rateLimit: z
28
- .union([
29
- z.boolean(),
30
- z.object({
31
- windowMs: z.number().optional(),
32
- max: z.number().optional(),
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
- .optional(),
36
- idempotency: z
37
- .union([z.boolean(), z.object({ ttlMs: z.number().optional() })])
38
- .optional(),
39
- realtime: z
40
- .union([
41
- z.boolean(),
42
- z.object({
43
- keepaliveMs: z.number().optional(),
44
- bufferSize: z.number().optional(),
45
- redis: z
46
- .union([
47
- z.string(),
48
- z.object({ url: z.string(), token: z.string().optional() }),
49
- ])
50
- .optional(),
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
- .optional(),
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 CRUD, realtime, storage,
77
- * and tRPC while keeping Bunderstack's auth handler available.
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
- * 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.
95
+ * Unified oRPC API builder callback.
94
96
  */
95
- routes?: (ctx: never) => unknown
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<TSchema extends Record<string, unknown>>(
126
- options: BunderstackConfig<TSchema>,
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 = RuntimeOptionsSchema.parse(options)
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 =