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.
@@ -1,14 +1,13 @@
1
1
  // src/jobs/define.ts — job definition types and the typed builder.
2
- // `createJobsBuilder` mirrors `createTRPC`: it exists purely to carry
2
+ // `createJobsBuilder` exists purely to carry
3
3
  // TSchema/TEnvResult typing into inline callbacks and extracted files.
4
- import type { ZodType } from 'zod'
4
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
5
5
 
6
6
  import type { DbFor } from '../db'
7
7
  import type { EmailFacade } from '../email'
8
8
  import type { StorageFacade } from '../index'
9
9
 
10
10
  import { parseCron } from './cron'
11
-
12
11
  import { CRON_PREFIX, type CatchUp } from './slots'
13
12
 
14
13
  export const DEFAULT_RETRIES = 3
@@ -33,7 +32,7 @@ export type TickResult = {
33
32
  }
34
33
 
35
34
  /**
36
- * The untyped runtime facade. Handler ctx and tRPC ctx expose this shape;
35
+ * The untyped runtime facade. Job handlers and API context expose this shape;
37
36
  * `app.jobs` narrows `enqueue` to the declared job names/payloads.
38
37
  */
39
38
  export type JobsRuntimeFacade = {
@@ -71,8 +70,8 @@ export type QueueJobDefinition<
71
70
  TEnvResult = Record<string, unknown>,
72
71
  > = {
73
72
  kind: 'job'
74
- /** zod schema for the payload; parsed at enqueue AND before the handler runs. */
75
- input?: ZodType<TInput>
73
+ /** Standard Schema payload; parsed at enqueue AND before the handler runs. */
74
+ input?: StandardSchemaV1<unknown, TInput>
76
75
  /** Attempts after the first failure. Default 3 (so 4 total attempts). */
77
76
  retries?: number
78
77
  /** Delay before retry N (1-based). Default exponential: 1s, 2s, 4s, … */
@@ -232,7 +231,7 @@ export function createJobsBuilder<
232
231
  TEnvResult = Record<string, unknown>,
233
232
  >() {
234
233
  return {
235
- /** Identity with inference: pins TInput from the zod schema. */
234
+ /** Identity with inference: pins TInput from the schema output. */
236
235
  job<TInput = undefined>(
237
236
  def: Omit<QueueJobDefinition<TInput, TSchema, TEnvResult>, 'kind'>,
238
237
  ): QueueJobDefinition<TInput, TSchema, TEnvResult> {
@@ -260,9 +259,8 @@ export type BunderstackJobsBuilder<
260
259
 
261
260
  // Infers TInput from the JobDefinition's own type argument rather than
262
261
  // pattern-matching the (optional, so union-with-undefined) `input` property —
263
- // `TDef extends { input: ZodType<infer I> }` fails structurally because
264
- // `input?: ZodType<TInput>` desugars to `ZodType<TInput> | undefined`, which
265
- // can never satisfy a required-property pattern.
262
+ // A required-property pattern fails structurally because `input` is optional,
263
+ // so infer from the definition's own type argument instead.
266
264
  type JobInputOf<TDef> =
267
265
  TDef extends QueueJobDefinition<infer TInput, any, any> ? TInput : undefined
268
266
 
package/src/jobs/queue.ts CHANGED
@@ -5,8 +5,8 @@ import type { AnyDb } from '../dialect'
5
5
  import type { EnqueueOptions, JobsDefs } from './define'
6
6
 
7
7
  import { jobsTableFor } from '../internal-tables'
8
+ import { validateStandardSchema } from '../standard-schema'
8
9
  import { generate } from '../typeid'
9
-
10
10
  import { CRON_PREFIX } from './slots'
11
11
 
12
12
  export async function enqueueJob(
@@ -23,7 +23,11 @@ export async function enqueueJob(
23
23
  const isCron = def.kind === 'cron'
24
24
  const type = isCron ? `${CRON_PREFIX}${name}` : name
25
25
  // Cron slots carry no payload; queue jobs validate theirs at the call site.
26
- const parsed = isCron ? null : def.input ? def.input.parse(input) : null
26
+ const parsed = isCron
27
+ ? null
28
+ : def.input
29
+ ? validateStandardSchema(def.input, input, `job "${name}" input`)
30
+ : null
27
31
  const t = jobsTableFor(db)
28
32
  const now = Date.now()
29
33
  const runAt =
@@ -12,6 +12,7 @@ import type {
12
12
  } from './define'
13
13
 
14
14
  import { jobsTableFor } from '../internal-tables'
15
+ import { validateStandardSchema } from '../standard-schema'
15
16
  import { parseCron } from './cron'
16
17
  import { backoffMs, DEFAULT_RETRIES, DEFAULT_TIMEOUT_MS } from './define'
17
18
  import { enqueueJob } from './queue'
@@ -73,7 +74,9 @@ export function createJobRunner(deps: {
73
74
  return { scheduledFor: new Date(Number(row.runAt)) }
74
75
  }
75
76
  const raw = JSON.parse(row.payloadJson)
76
- return def.input ? def.input.parse(raw) : undefined
77
+ return def.input
78
+ ? validateStandardSchema(def.input, raw, 'job payload')
79
+ : undefined
77
80
  }
78
81
 
79
82
  /**
package/src/manifest.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
2
+
1
3
  import { getTableName, isTable } from 'drizzle-orm'
2
- import { z, type ZodType } from 'zod'
4
+ import * as v from 'valibot'
3
5
 
4
6
  import type { Dialect } from './dialect'
5
7
  import type { EnvConfigInput } from './env'
@@ -12,6 +14,10 @@ import {
12
14
  bunderstackJobs,
13
15
  } from './internal-tables'
14
16
  import { parseCron } from './jobs/cron'
17
+ import {
18
+ StandardSchemaValidationError,
19
+ validateStandardSchema,
20
+ } from './standard-schema'
15
21
 
16
22
  export type ManifestEnvVar = {
17
23
  key: string
@@ -43,95 +49,77 @@ export type BunderstackManifest = {
43
49
  }
44
50
  }
45
51
 
46
- const nonEmpty = z.string().min(1)
47
- const migrationDirectory = nonEmpty.refine(
48
- (value) =>
49
- value.startsWith('/') ||
50
- (!value.includes('\\') &&
51
- value.split('/').every((part) => part !== '..' && part !== '')),
52
- {
53
- message:
54
- 'migrationsDirectory must be an absolute path or a relative path without traversal',
55
- },
52
+ const nonEmpty = v.pipe(v.string(), v.minLength(1))
53
+ const migrationDirectory = v.pipe(
54
+ nonEmpty,
55
+ v.check(
56
+ (value) =>
57
+ value.startsWith('/') ||
58
+ (!value.includes('\\') &&
59
+ value.split('/').every((part) => part !== '..' && part !== '')),
60
+ 'migrationsDirectory must be an absolute path or a relative path without traversal',
61
+ ),
56
62
  )
57
- const cronSchedule = nonEmpty.refine(
58
- (value) => {
63
+ const cronSchedule = v.pipe(
64
+ nonEmpty,
65
+ v.check((value) => {
59
66
  try {
60
67
  parseCron(value)
61
68
  return true
62
69
  } catch {
63
70
  return false
64
71
  }
65
- },
66
- { message: 'invalid cron schedule' },
72
+ }, 'invalid cron schedule'),
67
73
  )
68
74
 
69
- const manifestSchema = z
70
- .object({
71
- version: z.literal(3),
72
- database: z
73
- .object({
74
- dialect: z.enum(['sqlite', 'pg']),
75
- migrationsDirectory: migrationDirectory,
76
- tables: z.array(
77
- z
78
- .object({
79
- exportName: nonEmpty,
80
- physicalName: nonEmpty,
81
- system: z.boolean(),
82
- })
83
- .strict(),
84
- ),
85
- })
86
- .strict(),
87
- storage: z
88
- .object({
89
- defaultBucket: nonEmpty,
90
- buckets: z.array(
91
- z
92
- .object({
93
- name: nonEmpty,
94
- visibility: z.enum(['public', 'private']),
95
- })
96
- .strict(),
97
- ),
98
- })
99
- .strict(),
100
- realtime: z.object({ required: z.boolean() }).strict(),
101
- environment: z.array(
102
- z
103
- .object({
104
- key: nonEmpty,
105
- required: z.boolean(),
106
- scope: z.enum(['server', 'client']),
107
- })
108
- .strict(),
75
+ const manifestSchema = v.strictObject({
76
+ version: v.literal(3),
77
+ database: v.strictObject({
78
+ dialect: v.picklist(['sqlite', 'pg']),
79
+ migrationsDirectory: migrationDirectory,
80
+ tables: v.array(
81
+ v.strictObject({
82
+ exportName: nonEmpty,
83
+ physicalName: nonEmpty,
84
+ system: v.boolean(),
85
+ }),
109
86
  ),
110
- background: z
111
- .object({
112
- jobs: z.array(z.object({ name: nonEmpty }).strict()),
113
- cron: z.array(
114
- z
115
- .object({
116
- name: nonEmpty,
117
- schedule: cronSchedule,
118
- timezone: z.literal('UTC'),
119
- })
120
- .strict(),
121
- ),
122
- maintenance: z.array(
123
- z
124
- .object({
125
- name: z.literal('storage-sweep'),
126
- schedule: cronSchedule,
127
- timezone: z.literal('UTC'),
128
- })
129
- .strict(),
130
- ),
131
- })
132
- .strict(),
133
- })
134
- .strict()
87
+ }),
88
+ storage: v.strictObject({
89
+ defaultBucket: nonEmpty,
90
+ buckets: v.array(
91
+ v.strictObject({
92
+ name: nonEmpty,
93
+ visibility: v.picklist(['public', 'private']),
94
+ }),
95
+ ),
96
+ }),
97
+ realtime: v.strictObject({ required: v.boolean() }),
98
+ environment: v.array(
99
+ v.strictObject({
100
+ key: nonEmpty,
101
+ required: v.boolean(),
102
+ scope: v.picklist(['server', 'client']),
103
+ }),
104
+ ),
105
+ background: v.strictObject({
106
+ jobs: v.array(v.strictObject({ name: nonEmpty })),
107
+ cron: v.array(
108
+ v.strictObject({
109
+ name: nonEmpty,
110
+ schedule: cronSchedule,
111
+ timezone: v.literal('UTC'),
112
+ }),
113
+ ),
114
+ maintenance: v.array(
115
+ v.strictObject({
116
+ name: v.literal('storage-sweep'),
117
+ schedule: cronSchedule,
118
+ timezone: v.literal('UTC'),
119
+ }),
120
+ ),
121
+ }),
122
+ })
135
123
 
136
124
  function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
137
125
  return [...entries].sort((left, right) => key(left).localeCompare(key(right)))
@@ -161,14 +149,19 @@ function describeTables(schema: Record<string, unknown>) {
161
149
  }
162
150
 
163
151
  function describeSection(
164
- section: Record<string, ZodType> | undefined,
152
+ section: Record<string, StandardSchemaV1> | undefined,
165
153
  scope: ManifestEnvVar['scope'],
166
154
  ): ManifestEnvVar[] {
167
- return Object.entries(section ?? {}).map(([key, schema]) => ({
168
- key,
169
- required: !schema.safeParse(undefined).success,
170
- scope,
171
- }))
155
+ return Object.entries(section ?? {}).map(([key, schema]) => {
156
+ let required = false
157
+ try {
158
+ validateStandardSchema(schema, undefined, 'env')
159
+ } catch (error) {
160
+ if (!(error instanceof StandardSchemaValidationError)) throw error
161
+ required = true
162
+ }
163
+ return { key, required, scope }
164
+ })
172
165
  }
173
166
 
174
167
  function systemTables() {
@@ -192,7 +185,11 @@ function systemTables() {
192
185
  }
193
186
 
194
187
  export function parseManifest(value: unknown): BunderstackManifest {
195
- const manifest = manifestSchema.parse(value) as BunderstackManifest
188
+ const manifest = validateStandardSchema(
189
+ manifestSchema,
190
+ value,
191
+ 'manifest',
192
+ ) as BunderstackManifest
196
193
  rejectDuplicates(
197
194
  'database physical table',
198
195
  manifest.database.tables.map((entry) => entry.physicalName),
@@ -1,6 +1,9 @@
1
1
  import { getTableName, type InferSelectModel, type Table } from 'drizzle-orm'
2
2
 
3
- import type { RealtimeAction, RealtimeBroker } from './index'
3
+ import type {
4
+ RealtimeAction,
5
+ RealtimePublisher,
6
+ } from './publisher'
4
7
 
5
8
  export type RealtimeTransport = 'disabled' | 'memory' | 'redis'
6
9
 
@@ -23,30 +26,30 @@ export interface RealtimeFacade<
23
26
  }
24
27
 
25
28
  export function createRealtimeFacade<TSchema extends Record<string, unknown>>(
26
- broker?: RealtimeBroker,
27
- transport: RealtimeTransport = broker ? 'memory' : 'disabled',
29
+ publisher?: RealtimePublisher,
30
+ transport: RealtimeTransport = publisher ? 'memory' : 'disabled',
28
31
  ): RealtimeFacade<TSchema> {
29
- if (!broker && transport !== 'disabled') {
32
+ if (!publisher && transport !== 'disabled') {
30
33
  throw new Error(
31
- '[bunderstack] an enabled realtime transport requires a broker',
34
+ '[bunderstack] an enabled realtime transport requires a publisher',
32
35
  )
33
36
  }
34
- if (broker && transport === 'disabled') {
37
+ if (publisher && transport === 'disabled') {
35
38
  throw new Error(
36
- '[bunderstack] a realtime broker cannot use the disabled transport',
39
+ '[bunderstack] a realtime publisher cannot use the disabled transport',
37
40
  )
38
41
  }
39
42
 
40
43
  return {
41
- enabled: broker !== undefined,
44
+ enabled: publisher !== undefined,
42
45
  transport,
43
46
  async publish(table, action, record) {
44
- if (!broker) return
45
- await broker.publish(
46
- getTableName(table),
47
+ if (!publisher) return
48
+ await publisher.publish('change', {
49
+ table: getTableName(table),
47
50
  action,
48
- record as unknown as Record<string, unknown>,
49
- )
51
+ record: record as unknown as Record<string, unknown>,
52
+ })
50
53
  },
51
54
  }
52
55
  }
@@ -0,0 +1,77 @@
1
+ import { getEventMeta, withEventMeta } from '@orpc/server'
2
+
3
+ import type {
4
+ AccessUser,
5
+ ResolvedAccess,
6
+ } from '../access'
7
+ import type { RealtimeChange } from './publisher'
8
+
9
+ import {
10
+ checkAccess,
11
+ rowMatchesScope,
12
+ tableEntryForName,
13
+ } from '../access'
14
+
15
+ export interface FilterRealtimeChangesOptions {
16
+ subscriptions: readonly string[]
17
+ access: ResolvedAccess
18
+ request: Request
19
+ getSession: () => Promise<{
20
+ user: AccessUser | null
21
+ activeOrganizationId: string | null
22
+ }>
23
+ }
24
+
25
+ export async function* filterRealtimeChanges(
26
+ source: AsyncIterable<RealtimeChange>,
27
+ options: FilterRealtimeChangesOptions,
28
+ ): AsyncGenerator<RealtimeChange, void, void> {
29
+ const subscriptions = new Set(options.subscriptions)
30
+ let sessionPromise:
31
+ | ReturnType<FilterRealtimeChangesOptions['getSession']>
32
+ | undefined
33
+ const getSession = () => (sessionPromise ??= options.getSession())
34
+
35
+ for await (const change of source) {
36
+ const entry = tableEntryForName(options.access, change.table)
37
+ if (!entry?.enabled) continue
38
+
39
+ const recordId = change.record.id
40
+ if (
41
+ !subscriptions.has(change.table) &&
42
+ (recordId == null ||
43
+ !subscriptions.has(`${change.table}/${String(recordId)}`))
44
+ ) {
45
+ continue
46
+ }
47
+ if (entry.get === 'deny') continue
48
+
49
+ const needsSession = entry.get !== 'public' || entry.readScope !== undefined
50
+ const session = needsSession
51
+ ? await getSession()
52
+ : { user: null, activeOrganizationId: null }
53
+ const context = {
54
+ request: options.request,
55
+ user: session.user,
56
+ row: change.record,
57
+ session: { activeOrganizationId: session.activeOrganizationId },
58
+ }
59
+ if (!(await checkAccess(entry.get, context, entry.ownerColumn)).allowed) {
60
+ continue
61
+ }
62
+ if (
63
+ entry.readScope &&
64
+ !rowMatchesScope(change.record, entry.readScope(context))
65
+ ) {
66
+ continue
67
+ }
68
+
69
+ const projected: RealtimeChange = {
70
+ table: change.table,
71
+ action: change.action,
72
+ record: change.record,
73
+ }
74
+ const meta = getEventMeta(change)
75
+ yield meta ? withEventMeta(projected, meta) : projected
76
+ }
77
+ }
@@ -0,0 +1,80 @@
1
+ export const REALTIME_HEARTBEAT_INTERVAL_MS = 5_000
2
+
3
+ export type RealtimeHeartbeat = { type: 'heartbeat' }
4
+
5
+ type SourceState<T> =
6
+ | { status: 'pending' }
7
+ | { status: 'ready'; result: IteratorResult<T> }
8
+ | { status: 'error'; error: unknown }
9
+
10
+ /**
11
+ * Emits a transport-only event whenever the source has been idle for an
12
+ * interval. Heartbeats are deliberately not published, persisted, or assigned
13
+ * event IDs, so they do not affect replay and resume semantics.
14
+ */
15
+ export async function* withRealtimeHeartbeat<T>(
16
+ source: AsyncIterable<T>,
17
+ options: { intervalMs?: number; signal?: AbortSignal },
18
+ ): AsyncGenerator<T | RealtimeHeartbeat, void, void> {
19
+ const intervalMs = Math.max(
20
+ 1,
21
+ options.intervalMs ?? REALTIME_HEARTBEAT_INTERVAL_MS,
22
+ )
23
+ const iterator = source[Symbol.asyncIterator]()
24
+ let state: SourceState<T> = { status: 'pending' }
25
+ let wake: (() => void) | undefined
26
+ const getState = (): SourceState<T> => state
27
+
28
+ const requestNext = () => {
29
+ state = { status: 'pending' }
30
+ void iterator.next().then(
31
+ (result) => {
32
+ state = { status: 'ready', result }
33
+ wake?.()
34
+ },
35
+ (error: unknown) => {
36
+ state = { status: 'error', error }
37
+ wake?.()
38
+ },
39
+ )
40
+ }
41
+
42
+ requestNext()
43
+ try {
44
+ while (!options.signal?.aborted) {
45
+ let current = getState()
46
+ if (current.status === 'pending') {
47
+ await new Promise<void>((resolve) => {
48
+ let settled = false
49
+ const finish = () => {
50
+ if (settled) return
51
+ settled = true
52
+ clearTimeout(timer)
53
+ options.signal?.removeEventListener('abort', finish)
54
+ resolve()
55
+ }
56
+ const timer = setTimeout(finish, intervalMs)
57
+ options.signal?.addEventListener('abort', finish, { once: true })
58
+ wake = finish
59
+ })
60
+ wake = undefined
61
+
62
+ if (options.signal?.aborted) break
63
+ current = getState()
64
+ if (current.status === 'pending') {
65
+ yield { type: 'heartbeat' }
66
+ continue
67
+ }
68
+ }
69
+
70
+ if (current.status === 'error') throw current.error
71
+ if (current.result.done) break
72
+
73
+ yield current.result.value
74
+ requestNext()
75
+ }
76
+ } finally {
77
+ wake = undefined
78
+ await iterator.return?.()
79
+ }
80
+ }
@@ -0,0 +1,46 @@
1
+ import type { Publisher } from '@orpc/publisher'
2
+ import type { RedisClient } from 'bun'
3
+
4
+ import { BunRedisPublisher } from '@orpc/bun'
5
+ import { MemoryPublisher } from '@orpc/publisher/memory'
6
+
7
+ export type RealtimeAction = 'create' | 'update' | 'delete'
8
+
9
+ export interface RealtimeChange {
10
+ table: string
11
+ action: RealtimeAction
12
+ record: Record<string, unknown>
13
+ }
14
+
15
+ export interface RealtimeEvents extends Record<string, object> {
16
+ change: RealtimeChange
17
+ }
18
+
19
+ export type RealtimePublisher = Publisher<RealtimeEvents>
20
+
21
+ export interface RealtimePublisherOptions {
22
+ maxBufferedEvents?: number
23
+ resumeSeconds?: number
24
+ }
25
+
26
+ export function createMemoryRealtimePublisher(
27
+ options: RealtimePublisherOptions = {},
28
+ ): RealtimePublisher {
29
+ return new MemoryPublisher<RealtimeEvents>({
30
+ maxBufferedEvents: options.maxBufferedEvents,
31
+ resume: { enabled: true, seconds: options.resumeSeconds ?? 300 },
32
+ })
33
+ }
34
+
35
+ export function createRedisRealtimePublisher(
36
+ redis: RedisClient,
37
+ subscriber: RedisClient | Promise<RedisClient>,
38
+ options: RealtimePublisherOptions & { prefix?: string } = {},
39
+ ): RealtimePublisher {
40
+ return new BunRedisPublisher<RealtimeEvents>(redis, {
41
+ subscriber,
42
+ prefix: options.prefix ?? 'bunderstack:',
43
+ maxBufferedEvents: options.maxBufferedEvents,
44
+ resume: { enabled: true, seconds: options.resumeSeconds ?? 300 },
45
+ })
46
+ }
@@ -0,0 +1,59 @@
1
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
2
+
3
+ export type StandardSchema = StandardSchemaV1
4
+ export type InferStandardOutput<TSchema extends StandardSchemaV1> =
5
+ StandardSchemaV1.InferOutput<TSchema>
6
+
7
+ export type StandardSchemaIssue = {
8
+ path: PropertyKey[]
9
+ message: string
10
+ }
11
+
12
+ export class StandardSchemaValidationError extends Error {
13
+ readonly issues: StandardSchemaIssue[]
14
+
15
+ constructor(label: string, issues: readonly StandardSchemaV1.Issue[]) {
16
+ const normalized = issues.map((issue) => ({
17
+ path: [...(issue.path ?? [])].map((segment) =>
18
+ typeof segment === 'object' ? segment.key : segment,
19
+ ),
20
+ message: issue.message,
21
+ }))
22
+ super(
23
+ normalized
24
+ .map((issue) => {
25
+ const path = issue.path.map(String).join('.')
26
+ return `${label}${path ? `.${path}` : ''}: ${issue.message}`
27
+ })
28
+ .join('\n'),
29
+ )
30
+ this.name = 'StandardSchemaValidationError'
31
+ this.issues = normalized
32
+ }
33
+ }
34
+
35
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
36
+ return (
37
+ typeof value === 'object' &&
38
+ value !== null &&
39
+ 'then' in value &&
40
+ typeof value.then === 'function'
41
+ )
42
+ }
43
+
44
+ export function validateStandardSchema<TSchema extends StandardSchemaV1>(
45
+ schema: TSchema,
46
+ value: unknown,
47
+ label: string,
48
+ ): StandardSchemaV1.InferOutput<TSchema> {
49
+ const result = schema['~standard'].validate(value)
50
+ if (isPromiseLike(result)) {
51
+ throw new Error(
52
+ `[bunderstack] ${label} schema validation must be synchronous`,
53
+ )
54
+ }
55
+ if (result.issues) {
56
+ throw new StandardSchemaValidationError(label, result.issues)
57
+ }
58
+ return result.value
59
+ }
@@ -3,6 +3,14 @@ import { LocalStorageAdapter } from './local'
3
3
  import { S3StorageAdapter } from './s3'
4
4
 
5
5
  export type { LocalStorageAdapter, S3StorageAdapter }
6
+ export { createStorageOperations } from './operations'
7
+ export type {
8
+ PrepareUploadResult,
9
+ StorageDownload,
10
+ StorageExecutionContext,
11
+ StorageOperations,
12
+ StorageOperationsOptions,
13
+ } from './operations'
6
14
 
7
15
  export interface PresignPutOptions {
8
16
  contentType?: string