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.
Files changed (47) hide show
  1. package/README.md +25 -138
  2. package/package.json +22 -14
  3. package/src/access.ts +24 -1
  4. package/src/api/api-types.types.ts +106 -0
  5. package/src/api/builder.ts +52 -0
  6. package/src/api/context.ts +83 -0
  7. package/src/api/crud-router.ts +321 -0
  8. package/src/api/openapi.ts +184 -0
  9. package/src/api/realtime-router.ts +75 -0
  10. package/src/api/registry.ts +338 -0
  11. package/src/api/router.ts +34 -0
  12. package/src/api/storage-router.ts +224 -0
  13. package/src/api/types.ts +84 -0
  14. package/src/auth.ts +5 -0
  15. package/src/blueprint.ts +88 -105
  16. package/src/config.ts +73 -77
  17. package/src/cron.ts +2 -1
  18. package/src/crud-operations.ts +488 -0
  19. package/src/dialect.ts +1 -1
  20. package/src/env.ts +28 -21
  21. package/src/errors.ts +90 -23
  22. package/src/handler.ts +16 -44
  23. package/src/index.ts +283 -294
  24. package/src/internal-tables-pg.ts +1 -17
  25. package/src/internal-tables.ts +0 -31
  26. package/src/jobs/define.ts +75 -21
  27. package/src/jobs/index.ts +3 -9
  28. package/src/jobs/queue.ts +14 -6
  29. package/src/jobs/slots.ts +52 -0
  30. package/src/jobs/worker.ts +142 -42
  31. package/src/manifest.ts +84 -93
  32. package/src/realtime/facade.ts +16 -13
  33. package/src/realtime/filter.ts +77 -0
  34. package/src/realtime/heartbeat.ts +80 -0
  35. package/src/realtime/publisher.ts +46 -0
  36. package/src/standard-schema.ts +59 -0
  37. package/src/storage/index.ts +8 -0
  38. package/src/storage/operations.ts +398 -0
  39. package/src/crud.ts +0 -408
  40. package/src/jobs/cron-auth.ts +0 -28
  41. package/src/jobs/cron-router.ts +0 -135
  42. package/src/jobs/cron-runner.ts +0 -224
  43. package/src/jobs/local-cron.ts +0 -78
  44. package/src/realtime/index.ts +0 -250
  45. package/src/realtime/redis.ts +0 -228
  46. package/src/storage/router.ts +0 -531
  47. 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 = 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,
@@ -20,56 +22,38 @@ export type BetterAuthConfig = Omit<
20
22
  'database'
21
23
  >
22
24
 
23
- export const BunderstackOptionsSchema = z.object({
24
- schema: z.record(z.string(), z.unknown()),
25
- access: z.record(z.string(), z.unknown()).optional(),
26
- database: z
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(),
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
- .optional(),
55
- idempotency: z
56
- .union([z.boolean(), z.object({ ttlMs: z.number().optional() })])
57
- .optional(),
58
- realtime: z
59
- .union([
60
- z.boolean(),
61
- z.object({
62
- keepaliveMs: z.number().optional(),
63
- bufferSize: z.number().optional(),
64
- redis: z
65
- .union([
66
- z.string(),
67
- z.object({ url: z.string(), token: z.string().optional() }),
68
- ])
69
- .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
+ ),
70
53
  }),
71
- ])
72
- .optional(),
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
- > = Omit<
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
- > & {
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 CRUD, realtime, storage,
108
- * 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.
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
- // `trpc` is intentionally NOT declared here: createBunderstack intersects
115
- // its own inference-friendly `trpc` declaration (router | builder callback)
116
- // so the callback's `t` parameter gets contextual typing. `jobs` follows the
117
- // same pattern (defs map | builder callback receiving `j`).
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<TSchema extends Record<string, unknown>>(
148
- 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
+ >,
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 = BunderstackOptionsSchema.parse(options)
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
- parsed.database?.url ??
172
+ options.database?.url ??
177
173
  resolvedEnv.DATABASE_URL ??
178
174
  defaultUrl,
179
175
  authToken:
180
176
  platformSource['BUNDERSTACK_DATABASE_AUTH_TOKEN'] ??
181
- parsed.database?.authToken ??
177
+ options.database?.authToken ??
182
178
  resolvedEnv.DATABASE_AUTH_TOKEN,
183
- migrations: parsed.database?.migrations ?? './migrations',
179
+ migrations: options.database?.migrations ?? './migrations',
184
180
  },
185
181
  auth: (() => {
186
182
  const authInput = options.auth ?? {}
package/src/cron.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { cronMatches, parseCron } from './jobs/cron'
2
- export { signScheduleRequest, verifyScheduleRequest } from './jobs/cron-auth'
2
+ export { floorSlot, slotsDue, CRON_PREFIX, SLOT_MS } from './jobs/slots'
3
+ export type { CatchUp } from './jobs/slots'