bunderstack 0.24.6 → 0.25.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/llms-full.txt CHANGED
@@ -299,6 +299,7 @@ Major optional groups:
299
299
 
300
300
  - `access`: generated CRUD exposure, ownership, filters, sorting, and guards;
301
301
  - `auth` / `authResolver`: Better Auth and custom session resolution;
302
+ - `session`: an explicit projection of extra authenticated-user fields;
302
303
  - `env`: declared environment, validated before anything reads it;
303
304
  - `storage`, `messaging`, and `jobs`: application facilities;
304
305
  - `api`: your oRPC router, as an object or as `(o) => router`;
@@ -392,6 +393,30 @@ type ApiContext = {
392
393
  `o.protected` adds non-null `context.user` and the active organization session
393
394
  to handlers. Inputs and optional outputs accept Standard Schema.
394
395
 
396
+ `AccessUser` includes Better Auth's `emailVerified` field. Session resolution
397
+ normalizes it fail-closed: only the literal value `true` is treated as verified.
398
+ Use `defineSessionUser` to expose additional application fields without copying
399
+ the complete Better Auth user into every procedure:
400
+
401
+ ```ts
402
+ const session = defineSessionUser({
403
+ mapUser: (user) => ({ clinicId: String(user.clinicId) }),
404
+ })
405
+
406
+ const o = defineApi({ schema, session })
407
+
408
+ const backend = bunderstack({
409
+ schema,
410
+ database,
411
+ session,
412
+ api,
413
+ })
414
+ ```
415
+
416
+ The mapped fields are inferred on `getSession().user` and `o.protected`'s
417
+ `context.user`. `id`, `email`, `emailVerified`, `name`, and `role` remain
418
+ framework-owned and cannot be overridden by the mapper.
419
+
395
420
  `peekSession()` exists for graph-wide middleware, which runs before
396
421
  authentication. Use it for observability only, never for authorization — see
397
422
  [Middleware](/docs/middleware#reading-the-caller).
@@ -401,6 +426,7 @@ authentication. Use it for observability only, never for authorization — see
401
426
  | Export | Purpose |
402
427
  | ---------------------------- | --------------------------------------------------------- |
403
428
  | `defineApi({ schema, env })` | The procedure builder, with generics inferred from values |
429
+ | `defineSessionUser(config)` | A typed, explicit projection of extra session user fields |
404
430
  | `listSpec(table, options)` | Input schema and handler for a list endpoint outside CRUD |
405
431
  | `BunderstackError` | Typed error for code outside a handler |
406
432
  | `BunderstackDb<TSchema>` | The database type for a helper parameter |
@@ -815,6 +841,80 @@ is best effort and can race; `concurrency` is not a strict provider-wide
815
841
  semaphore. Use an application/provider limiter when several workers share one
816
842
  external quota.
817
843
 
844
+ ## Deduplication
845
+
846
+ A `dedupeKey` collapses enqueues of the same job type into one row. The second
847
+ call returns the existing row's id instead of inserting another:
848
+
849
+ ```ts
850
+ await app.jobs.enqueue('syncPerson', { personId }, { dedupeKey: personId })
851
+ ```
852
+
853
+ `dedupeUntil` on the job declaration decides how long the key collapses new
854
+ enqueues:
855
+
856
+ - **`'finish'` (default)** — the key is held until the job succeeds or finally
857
+ fails. An enqueue while the job runs returns the running row. Right for "at
858
+ most one of these at a time" work.
859
+ - **`'start'`** — the key is released when a worker claims the job. A burst of
860
+ enqueues before the claim still collapses into one row, but an enqueue during
861
+ the run creates one more row that runs after it and reads the newer state.
862
+ Right for debounced sync, reindexing, and cache rebuilds.
863
+
864
+ ```ts
865
+ syncPerson: j.job({
866
+ input: v.object({ personId: v.string() }),
867
+ dedupeUntil: 'start',
868
+ retries: 5,
869
+ handler: async ({ personId }, ctx) => {
870
+ // Read the latest state, then push it.
871
+ },
872
+ })
873
+ ```
874
+
875
+ With `'start'`, a job that fails and waits for a retry no longer holds its key,
876
+ so a newer row can exist beside it and both run. `'start'` also does not
877
+ serialize runs per key: two workers can run an older and a newer row at the
878
+ same time. Keep the default, or take a lock in the handler, when runs for one
879
+ key must never overlap.
880
+
881
+ Cron tasks reject `dedupeUntil`; their slot ownership depends on the retained
882
+ key.
883
+
884
+ ## Transactional enqueue
885
+
886
+ Pass `tx` to insert the job row through your own Drizzle transaction. The job
887
+ becomes visible to workers only when the transaction commits, and a rollback
888
+ removes it. Your data and the work that follows from it are written atomically,
889
+ so you do not need an outbox table:
890
+
891
+ ```ts
892
+ await context.db.transaction(async (tx) => {
893
+ const person = await mergePerson(tx, event)
894
+ await context.jobs.enqueue(
895
+ 'syncPerson',
896
+ { personId: person.id },
897
+ { tx, dedupeKey: `sync:${person.id}` },
898
+ )
899
+ })
900
+ ```
901
+
902
+ `tx` works on `app.jobs`, on `context.jobs` in procedures, on `ctx.jobs` in job
903
+ handlers, and on `fixture.app.jobs` in tests. It must be a transaction on the
904
+ app's own database. A transaction of the other dialect throws; a transaction
905
+ from an unrelated connection of the same dialect is not supported.
906
+
907
+ - **Postgres:** an enqueue whose `dedupeKey` conflicts with a row that another
908
+ open transaction inserted waits for that transaction to end. Two transactions
909
+ that enqueue the same key therefore run one after the other. Keep such
910
+ transactions short.
911
+ - **SQLite and libSQL:** a write transaction already holds the database write
912
+ lock, so `tx` adds no contention.
913
+
914
+ Delivery stays at-least-once. `tx` removes lost jobs (a crash between commit and
915
+ enqueue) and phantom jobs (an enqueue whose transaction rolled back). It does
916
+ not make handlers exactly-once.
917
+
818
918
  ## Cron
819
919
 
820
920
  `j.cron()` uses a five-field UTC schedule. Each due minute is materialized as a
@@ -964,6 +1064,7 @@ const backend = bunderstack({
964
1064
  access,
965
1065
  auth,
966
1066
  authResolver,
1067
+ session,
967
1068
  storage,
968
1069
  messaging,
969
1070
  jobs: (j) => j.define({}),
package/llms.txt CHANGED
@@ -325,6 +325,27 @@ One table, one loop. A cron is a job created on a schedule.
325
325
  Enqueue with app.jobs.enqueue(name, input, { dedupeKey }). The background loop
326
326
  starts on its own, gated by BUNDERSTACK_ROLE: all (default), web, or worker.
327
327
 
328
+ Pass { tx } to insert the job row through your own Drizzle transaction. The job
329
+ becomes visible to workers only when the transaction commits; a rollback
330
+ removes it. Use it instead of an application outbox table:
331
+
332
+ await context.db.transaction(async (tx) => {
333
+ const person = await mergePerson(tx, event)
334
+ await context.jobs.enqueue('syncPerson', { personId: person.id },
335
+ { tx, dedupeKey: `sync:${person.id}` })
336
+ })
337
+
338
+ The tx must belong to the app's own database; a tx of the other dialect
339
+ throws. On Postgres, two open transactions that enqueue the same dedupeKey
340
+ serialize on it. Delivery stays at-least-once.
341
+
342
+ dedupeKey collapses enqueues until the job finishes (dedupeUntil: 'finish', the
343
+ default). Declare dedupeUntil: 'start' on j.job() to release the key when a
344
+ worker claims the job: a burst before the claim still collapses into one row,
345
+ and an enqueue during the run creates one more row that reads the newer state.
346
+ Use it for debounced sync, reindex, and cache rebuilds. 'start' does not
347
+ serialize runs per key. j.cron() rejects dedupeUntil.
348
+
328
349
  STORAGE
329
350
 
330
351
  storage: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunderstack",
3
- "version": "0.24.6",
3
+ "version": "0.25.0",
4
4
  "description": "Batteries-included backend framework for Bun: type-safe oRPC APIs, auth, storage, realtime, jobs, messaging, and validated env from one declaration.",
5
5
  "keywords": [
6
6
  "backend",