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
@@ -67,20 +67,4 @@ export const bunderstackJobsPg = pgTable(
67
67
  ],
68
68
  )
69
69
 
70
- export const bunderstackCronRunsPg = pgTable(
71
- '_bunderstack_cron_runs',
72
- {
73
- taskId: text('task_id').notNull(),
74
- scheduledAt: bigint('scheduled_at', { mode: 'number' }).notNull(),
75
- status: text('status').notNull(),
76
- attempts: integer('attempts').notNull().default(0),
77
- lockedUntil: bigint('locked_until', { mode: 'number' }),
78
- lastError: text('last_error'),
79
- startedAt: bigint('started_at', { mode: 'number' }),
80
- finishedAt: bigint('finished_at', { mode: 'number' }),
81
- },
82
- (t) => [
83
- primaryKey({ columns: [t.taskId, t.scheduledAt] }),
84
- index('bcr_claim').on(t.status, t.lockedUntil),
85
- ],
86
- )
70
+
@@ -12,7 +12,6 @@ import {
12
12
  import { detectDialect } from './dialect'
13
13
  import {
14
14
  bunderstackFilesPg,
15
- bunderstackCronRunsPg,
16
15
  bunderstackIdempotencyPg,
17
16
  bunderstackJobsPg,
18
17
  } from './internal-tables-pg'
@@ -74,43 +73,22 @@ export const bunderstackJobs = sqliteTable(
74
73
  ],
75
74
  )
76
75
 
77
- export const bunderstackCronRuns = sqliteTable(
78
- '_bunderstack_cron_runs',
79
- {
80
- taskId: text('task_id').notNull(),
81
- scheduledAt: integer('scheduled_at').notNull(),
82
- status: text('status').notNull(),
83
- attempts: integer('attempts').notNull().default(0),
84
- lockedUntil: integer('locked_until'),
85
- lastError: text('last_error'),
86
- startedAt: integer('started_at'),
87
- finishedAt: integer('finished_at'),
88
- },
89
- (t) => [
90
- primaryKey({ columns: [t.taskId, t.scheduledAt] }),
91
- index('bcr_claim').on(t.status, t.lockedUntil),
92
- ],
93
- )
94
-
95
76
  export const INTERNAL_TABLES = {
96
77
  bunderstackFiles,
97
78
  bunderstackIdempotency,
98
79
  bunderstackJobs,
99
- bunderstackCronRuns,
100
80
  } as const
101
81
 
102
82
  export const INTERNAL_TABLE_NAMES: ReadonlySet<string> = new Set([
103
83
  'bunderstack_file_meta',
104
84
  '_bunderstack_idempotency',
105
85
  '_bunderstack_jobs',
106
- '_bunderstack_cron_runs',
107
86
  ])
108
87
 
109
88
  export const INTERNAL_TABLES_PG = {
110
89
  bunderstackFiles: bunderstackFilesPg,
111
90
  bunderstackIdempotency: bunderstackIdempotencyPg,
112
91
  bunderstackJobs: bunderstackJobsPg,
113
- bunderstackCronRuns: bunderstackCronRunsPg,
114
92
  } as const
115
93
 
116
94
  // Both dialect twins count as "ours" for the re-export identity check.
@@ -121,10 +99,6 @@ const INTERNAL_TABLE_CANDIDATES = new Map<string, readonly unknown[]>([
121
99
  [bunderstackIdempotency, bunderstackIdempotencyPg],
122
100
  ],
123
101
  [getTableName(bunderstackJobs), [bunderstackJobs, bunderstackJobsPg]],
124
- [
125
- getTableName(bunderstackCronRuns),
126
- [bunderstackCronRuns, bunderstackCronRunsPg],
127
- ],
128
102
  ])
129
103
 
130
104
  /** Internal file-meta table matching the db's dialect. */
@@ -142,11 +116,6 @@ export function jobsTableFor(db: unknown) {
142
116
  return is(db, PgDatabase) ? bunderstackJobsPg : bunderstackJobs
143
117
  }
144
118
 
145
- /** Internal cron-run table matching the db's dialect. */
146
- export function cronRunsTableFor(db: unknown) {
147
- return is(db, PgDatabase) ? bunderstackCronRunsPg : bunderstackCronRuns
148
- }
149
-
150
119
  export function withInternalTables<TSchema extends Record<string, unknown>>(
151
120
  schema: TSchema,
152
121
  ): TSchema & typeof INTERNAL_TABLES {
@@ -1,13 +1,14 @@
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
+ import { CRON_PREFIX, type CatchUp } from './slots'
11
12
 
12
13
  export const DEFAULT_RETRIES = 3
13
14
  export const DEFAULT_TIMEOUT_MS = 60_000
@@ -21,8 +22,17 @@ export type EnqueueOptions = {
21
22
  runAt?: Date | number
22
23
  }
23
24
 
25
+ export type TickResult = {
26
+ /** Rows moved from pending to running this tick. */
27
+ claimed: number
28
+ /** Handlers that completed successfully. */
29
+ ran: number
30
+ /** Handlers that threw, whether or not they will be retried. */
31
+ failed: number
32
+ }
33
+
24
34
  /**
25
- * 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;
26
36
  * `app.jobs` narrows `enqueue` to the declared job names/payloads.
27
37
  */
28
38
  export type JobsRuntimeFacade = {
@@ -32,7 +42,7 @@ export type JobsRuntimeFacade = {
32
42
  opts?: EnqueueOptions,
33
43
  ): Promise<{ id: string }>
34
44
  /** Run one poll cycle deterministically (tests). `now` defaults to Date.now(). */
35
- tick(now?: number): Promise<void>
45
+ tick(now?: number): Promise<TickResult>
36
46
  }
37
47
 
38
48
  import type { RealtimeFacade } from '../realtime/facade'
@@ -60,13 +70,13 @@ export type QueueJobDefinition<
60
70
  TEnvResult = Record<string, unknown>,
61
71
  > = {
62
72
  kind: 'job'
63
- /** zod schema for the payload; parsed at enqueue AND before the handler runs. */
64
- input?: ZodType<TInput>
73
+ /** Standard Schema payload; parsed at enqueue AND before the handler runs. */
74
+ input?: StandardSchemaV1<unknown, TInput>
65
75
  /** Attempts after the first failure. Default 3 (so 4 total attempts). */
66
76
  retries?: number
67
77
  /** Delay before retry N (1-based). Default exponential: 1s, 2s, 4s, … */
68
78
  backoff?: ((attempt: number) => number) | { baseMs?: number; factor?: number }
69
- /** Max simultaneous `running` rows of this type, enforced cross-replica. */
79
+ /** Max simultaneous `running` rows of this type, enforced per worker. */
70
80
  concurrency?: number
71
81
  /** Lease duration in ms; an expired lease sends the job back to pending. */
72
82
  timeout?: number
@@ -91,10 +101,26 @@ export type CronDefinition<
91
101
  > = {
92
102
  kind: 'cron'
93
103
  schedule: TSchedule
104
+ /** Attempts after the first failure. Default 3 (so 4 total attempts). */
105
+ retries?: number
106
+ /** Delay before retry N (1-based). Default exponential: 1s, 2s, 4s, … */
107
+ backoff?: ((attempt: number) => number) | { baseMs?: number; factor?: number }
108
+ /** Lease duration in ms; an expired lease sends the slot back to pending. */
109
+ timeout?: number
110
+ /** How missed slots are handled on wake. Default 'latest'. */
111
+ catchUp?: CatchUp
112
+ /** How far back catch-up looks, in ms. Default 1 hour. */
113
+ catchUpWindow?: number
94
114
  handler: (
95
115
  invocation: CronInvocation,
96
116
  ctx: JobContext<TSchema, TEnvResult>,
97
117
  ) => Promise<void> | void
118
+ /** Fires once, after the final attempt fails. Errors here are logged, never retried. */
119
+ onFailed?: (
120
+ invocation: CronInvocation,
121
+ error: Error,
122
+ ctx: JobContext<TSchema, TEnvResult>,
123
+ ) => Promise<void> | void
98
124
  }
99
125
 
100
126
  export type BackgroundDefinition =
@@ -111,6 +137,10 @@ export type JobDefinition<
111
137
 
112
138
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
113
139
  export type AnyJobDefinition = QueueJobDefinition<any, any, any>
140
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
141
+ export type AnyBackgroundDefinition =
142
+ | QueueJobDefinition<any, any, any>
143
+ | CronDefinition<any, any, any>
114
144
  export type JobsDefs = BackgroundDefs
115
145
 
116
146
  export type QueueJobKeys<TDefs extends BackgroundDefs> = {
@@ -129,18 +159,38 @@ export function validateBackgroundDefs(defs: BackgroundDefs): void {
129
159
  if (typeof def.handler !== 'function') {
130
160
  throw new Error(`[bunderstack] background task "${name}" has no handler`)
131
161
  }
132
- if (def.kind === 'cron') {
133
- parseCron(def.schedule)
134
- continue
162
+ if (def.kind === 'job' && name.startsWith(CRON_PREFIX)) {
163
+ throw new Error(
164
+ `[bunderstack] job "${name}": the "${CRON_PREFIX}" prefix is reserved for cron tasks`,
165
+ )
135
166
  }
136
167
  if (
137
168
  def.retries !== undefined &&
138
169
  (def.retries < 0 || !Number.isInteger(def.retries))
139
170
  ) {
140
171
  throw new Error(
141
- `[bunderstack] job "${name}": retries must be a non-negative integer`,
172
+ `[bunderstack] background task "${name}": retries must be a non-negative integer`,
173
+ )
174
+ }
175
+ if (def.timeout !== undefined && def.timeout <= 0) {
176
+ throw new Error(
177
+ `[bunderstack] background task "${name}": timeout must be positive`,
142
178
  )
143
179
  }
180
+ if (def.kind === 'cron') {
181
+ parseCron(def.schedule)
182
+ if ((def as { concurrency?: number }).concurrency !== undefined) {
183
+ throw new Error(
184
+ `[bunderstack] cron "${name}": concurrency is not supported for cron tasks — slots are already unique`,
185
+ )
186
+ }
187
+ if (def.catchUpWindow !== undefined && def.catchUpWindow <= 0) {
188
+ throw new Error(
189
+ `[bunderstack] cron "${name}": catchUpWindow must be positive`,
190
+ )
191
+ }
192
+ continue
193
+ }
144
194
  if (
145
195
  def.concurrency !== undefined &&
146
196
  (def.concurrency < 1 || !Number.isInteger(def.concurrency))
@@ -149,22 +199,27 @@ export function validateBackgroundDefs(defs: BackgroundDefs): void {
149
199
  `[bunderstack] job "${name}": concurrency must be a positive integer`,
150
200
  )
151
201
  }
152
- if (def.timeout !== undefined && def.timeout <= 0) {
153
- throw new Error(`[bunderstack] job "${name}": timeout must be positive`)
154
- }
155
202
  }
156
203
  }
157
204
 
158
205
  /** @deprecated Use validateBackgroundDefs. */
159
206
  export const validateJobsDefs = validateBackgroundDefs
160
207
 
161
- /** Delay in ms before retry `attempt` (1-based = the attempt that just failed). */
162
- export function backoffMs(def: AnyJobDefinition, attempt: number): number {
208
+ /**
209
+ * Delay in ms before retry `attempt` (1-based = the attempt that just failed).
210
+ * Jittered by ±20% so a shared outage does not retry every job in lockstep.
211
+ * A caller-supplied backoff function is returned verbatim — the caller owns it.
212
+ */
213
+ export function backoffMs(
214
+ def: AnyBackgroundDefinition,
215
+ attempt: number,
216
+ ): number {
163
217
  const b = def.backoff
164
218
  if (typeof b === 'function') return b(attempt)
165
219
  const baseMs = b?.baseMs ?? 1000
166
220
  const factor = b?.factor ?? 2
167
- return baseMs * factor ** (attempt - 1)
221
+ const flat = baseMs * factor ** (attempt - 1)
222
+ return Math.round(flat * (0.8 + Math.random() * 0.4))
168
223
  }
169
224
 
170
225
  /**
@@ -176,7 +231,7 @@ export function createJobsBuilder<
176
231
  TEnvResult = Record<string, unknown>,
177
232
  >() {
178
233
  return {
179
- /** Identity with inference: pins TInput from the zod schema. */
234
+ /** Identity with inference: pins TInput from the schema output. */
180
235
  job<TInput = undefined>(
181
236
  def: Omit<QueueJobDefinition<TInput, TSchema, TEnvResult>, 'kind'>,
182
237
  ): QueueJobDefinition<TInput, TSchema, TEnvResult> {
@@ -204,9 +259,8 @@ export type BunderstackJobsBuilder<
204
259
 
205
260
  // Infers TInput from the JobDefinition's own type argument rather than
206
261
  // pattern-matching the (optional, so union-with-undefined) `input` property —
207
- // `TDef extends { input: ZodType<infer I> }` fails structurally because
208
- // `input?: ZodType<TInput>` desugars to `ZodType<TInput> | undefined`, which
209
- // 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.
210
264
  type JobInputOf<TDef> =
211
265
  TDef extends QueueJobDefinition<infer TInput, any, any> ? TInput : undefined
212
266
 
package/src/jobs/index.ts CHANGED
@@ -25,19 +25,13 @@ export type {
25
25
  } from './define'
26
26
  export { enqueueJob } from './queue'
27
27
  export { createJobRunner } from './worker'
28
- export { runCronSlot, runScheduledSlot } from './cron-runner'
29
- export type { CronRunResult } from './cron-runner'
30
- export { buildCronRouter } from './cron-router'
31
- export { signScheduleRequest, verifyScheduleRequest } from './cron-auth'
32
28
  export { startJobWorker } from './runtime'
33
29
  export type {
34
30
  StartWorkerOptions,
35
31
  RunWorkerOptions,
36
32
  WorkerHandle,
37
33
  } from './runtime'
38
- export { startLocalCronScheduler } from './local-cron'
39
- export type {
40
- LocalCronScheduler,
41
- LocalCronSchedulerOptions,
42
- } from './local-cron'
43
34
  export { parseCron, cronMatches } from './cron'
35
+ export { slotsDue, floorSlot, CRON_PREFIX, SLOT_MS } from './slots'
36
+ export type { CatchUp } from './slots'
37
+ export type { TickResult } from './define'
package/src/jobs/queue.ts CHANGED
@@ -5,7 +5,9 @@ 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'
10
+ import { CRON_PREFIX } from './slots'
9
11
 
10
12
  export async function enqueueJob(
11
13
  db: AnyDb,
@@ -15,11 +17,17 @@ export async function enqueueJob(
15
17
  opts: EnqueueOptions = {},
16
18
  ): Promise<{ id: string }> {
17
19
  const def = defs[name]
18
- if (!def || def.kind !== 'job') {
19
- throw new Error(`[bunderstack] unknown queue job "${name}"`)
20
+ if (!def) {
21
+ throw new Error(`[bunderstack] unknown background task "${name}"`)
20
22
  }
21
- // Fail fast: a bad payload should throw at the call site, not in the worker.
22
- const parsed = def.input ? def.input.parse(input) : null
23
+ const isCron = def.kind === 'cron'
24
+ const type = isCron ? `${CRON_PREFIX}${name}` : name
25
+ // Cron slots carry no payload; queue jobs validate theirs at the call site.
26
+ const parsed = isCron
27
+ ? null
28
+ : def.input
29
+ ? validateStandardSchema(def.input, input, `job "${name}" input`)
30
+ : null
23
31
  const t = jobsTableFor(db)
24
32
  const now = Date.now()
25
33
  const runAt =
@@ -35,7 +43,7 @@ export async function enqueueJob(
35
43
  .insert(t)
36
44
  .values({
37
45
  id,
38
- type: name,
46
+ type,
39
47
  payloadJson: JSON.stringify(parsed),
40
48
  status: 'pending',
41
49
  attempts: 0,
@@ -49,7 +57,7 @@ export async function enqueueJob(
49
57
  const existing = await db
50
58
  .select({ id: t.id })
51
59
  .from(t)
52
- .where(and(eq(t.type, name), eq(t.dedupeKey, opts.dedupeKey ?? '')))
60
+ .where(and(eq(t.type, type), eq(t.dedupeKey, opts.dedupeKey ?? '')))
53
61
  .limit(1)
54
62
  if (existing[0]) return { id: String(existing[0].id) }
55
63
  }
@@ -0,0 +1,52 @@
1
+ // src/jobs/slots.ts — cron slot enumeration. Pure; no db, no clock reads.
2
+ import { cronMatches, type ParsedCron } from './cron'
3
+
4
+ /** Slot granularity. Every slot timestamp satisfies `slot % SLOT_MS === 0`. */
5
+ export const SLOT_MS = 60_000
6
+
7
+ /** Reserved job-type prefix for cron occurrences. */
8
+ export const CRON_PREFIX = 'cron:'
9
+
10
+ /** How far back either catch-up mode will look. */
11
+ export const DEFAULT_CATCH_UP_WINDOW_MS = 60 * SLOT_MS
12
+
13
+ export type CatchUp = 'latest' | 'all'
14
+
15
+ /** Aligns `ms` down to its containing slot. */
16
+ export function floorSlot(ms: number): number {
17
+ return Math.floor(ms / SLOT_MS) * SLOT_MS
18
+ }
19
+
20
+ /**
21
+ * Slots matching `cron` in the half-open range `(from, to]`, oldest first.
22
+ *
23
+ * `from` is exclusive so a stored watermark is never re-emitted. Both modes are
24
+ * clamped to `catchUpWindowMs` — without it a watermark far in the past would
25
+ * make this iterate unbounded minutes.
26
+ */
27
+ export function slotsDue(args: {
28
+ cron: ParsedCron
29
+ from: number
30
+ to: number
31
+ catchUp?: CatchUp
32
+ catchUpWindowMs?: number
33
+ }): number[] {
34
+ const catchUp = args.catchUp ?? 'latest'
35
+ const windowMs = args.catchUpWindowMs ?? DEFAULT_CATCH_UP_WINDOW_MS
36
+ const to = floorSlot(args.to)
37
+ const from = Math.max(floorSlot(args.from), to - windowMs)
38
+ if (to <= from) return []
39
+
40
+ if (catchUp === 'latest') {
41
+ for (let s = to; s > from; s -= SLOT_MS) {
42
+ if (cronMatches(args.cron, s)) return [s]
43
+ }
44
+ return []
45
+ }
46
+
47
+ const slots: number[] = []
48
+ for (let s = from + SLOT_MS; s <= to; s += SLOT_MS) {
49
+ if (cronMatches(args.cron, s)) slots.push(s)
50
+ }
51
+ return slots
52
+ }