@murumets-ee/entity 0.10.0 → 0.12.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.
@@ -36,78 +36,6 @@ interface CountCacheLike {
36
36
  invalidate(prefix: string): void;
37
37
  }
38
38
  //#endregion
39
- //#region src/cursor.d.ts
40
- /** Cursor input for keyset pagination. */
41
- interface CursorInput {
42
- /** Sort field name (e.g. 'createdAt'). Must be a real column on the entity. */
43
- field: string;
44
- /** Last seen value of the sort field. */
45
- value: string | number;
46
- /** Sort direction — must match the ORDER BY direction. */
47
- direction: 'asc' | 'desc';
48
- /** Tie-breaker: last seen entity ID. Required for non-unique sort fields. */
49
- id?: string;
50
- }
51
- //#endregion
52
- //#region src/admin-config.d.ts
53
- /**
54
- * Optional admin UI configuration for entities.
55
- * Controls how entities appear in the admin sidebar, list pages, and forms.
56
- */
57
- interface EntityAdminConfig {
58
- /** Sidebar section: 'content' | 'structure' | custom string. Default: 'content' */
59
- group?: string;
60
- /** Plural display name for sidebar + list pages. Default: title-cased pluralized entity name */
61
- label?: string;
62
- /** Singular label for "New X" button. Default: title-cased entity name */
63
- labelSingular?: string;
64
- /** Lucide icon name as string, e.g. 'file-text' */
65
- icon?: string;
66
- /** Description shown on list page */
67
- description?: string;
68
- /** Form layout. Default: 'single' */
69
- layout?: 'single' | 'two-column';
70
- /** Fields to hide in the form */
71
- hiddenFields?: string[];
72
- /** Columns to hide in the list */
73
- hiddenColumns?: string[];
74
- /** Per-field label/description/placeholder overrides */
75
- fieldOverrides?: Record<string, {
76
- label?: string;
77
- description?: string;
78
- placeholder?: string;
79
- }>;
80
- /** Default list sort field. Default: 'createdAt' */
81
- defaultSort?: string;
82
- /** Default list sort direction. Default: 'desc' */
83
- defaultSortDirection?: 'asc' | 'desc';
84
- /** List page size. Default: 20 */
85
- pageSize?: number;
86
- /** For block editor: fields to show in Puck root config */
87
- rootFields?: string[];
88
- /** Suppress "New" button in the list page */
89
- disableCreate?: boolean;
90
- /** Order within sidebar group. Default: 0 */
91
- sortOrder?: number;
92
- /**
93
- * Hide the entity from BOTH dashboard and sidebar.
94
- * Use for system entities that should not appear in any UI.
95
- * Shorthand for `hideFromMenu: true` + `hideFromDashboard: true`.
96
- */
97
- hidden?: boolean;
98
- /**
99
- * Hide from sidebar menu only. Entity remains accessible via dashboard, direct URL,
100
- * and the entity API. Use for storage models accessed through specialized UI
101
- * (e.g., Ticket entities accessed via the inbox/board, not as a CRUD table).
102
- */
103
- hideFromMenu?: boolean;
104
- /**
105
- * Hide from dashboard widget grid only. Entity still appears in sidebar.
106
- * Use for entities that don't have meaningful counts/stats to show.
107
- */
108
- hideFromDashboard?: boolean;
109
- }
110
- //#endregion
111
39
  //#region src/fields/base.d.ts
112
40
  /**
113
41
  * Field type definitions
@@ -119,6 +47,23 @@ interface BaseFieldConfig {
119
47
  translatable?: boolean;
120
48
  indexed?: boolean;
121
49
  unique?: boolean;
50
+ /**
51
+ * Marks the field as **system-managed**: stored as a real column, populated
52
+ * by behavior hooks or trusted server-side transitions, but NOT writable
53
+ * through the public `AdminClient.create/update` surface.
54
+ *
55
+ * - Caller-supplied values for internal fields are silently stripped before
56
+ * hooks run (so an HTTP PATCH cannot poison a workflow state).
57
+ * - `beforeCreate` / `beforeUpdate` hooks may still set them (they run after
58
+ * the strip), and the values are preserved through validation.
59
+ * - Trusted server code that needs to write internals directly — e.g.
60
+ * workflow transitions invoked from an authorized admin route — must use
61
+ * `AdminClient.updateInternal()`, which bypasses the strip.
62
+ *
63
+ * Use this flag on any field added by a behavior whose value represents a
64
+ * controlled state machine (e.g. `_workflowStatus`), not user input.
65
+ */
66
+ internal?: boolean;
122
67
  access?: {
123
68
  view?: string;
124
69
  edit?: string;
@@ -199,12 +144,138 @@ interface BlocksField extends BaseFieldConfig {
199
144
  }
200
145
  type FieldConfig = IdField | TextField | NumberField | BooleanField | DateField | SelectField | ReferenceField | MediaField | RichTextField | SlugField | JsonField | BlocksField;
201
146
  //#endregion
147
+ //#region src/types/infer.d.ts
148
+ /**
149
+ * Recursive JSON-compatible value, for `field.json()` (jsonb column).
150
+ * Mirrors what Postgres jsonb can store: primitives, arrays, or objects.
151
+ */
152
+ type JsonValue = string | number | boolean | null | JsonValue[] | {
153
+ [key: string]: JsonValue;
154
+ };
155
+ /**
156
+ * Maps a single FieldConfig to its TypeScript output type.
157
+ * Each branch is a shallow comparison — no recursion.
158
+ */
159
+ type FieldToTS<F extends FieldConfig> = F extends IdField ? string : F extends TextField ? string : F extends NumberField ? number : F extends BooleanField ? boolean : F extends DateField ? Date | string : F extends SelectField ? F['options'][number] : F extends ReferenceField ? F['cardinality'] extends 'many' ? string[] : string : F extends MediaField ? string : F extends RichTextField ? Record<string, unknown>[] : F extends SlugField ? string : F extends JsonField ? JsonValue : F extends BlocksField ? Array<{
160
+ _block: string;
161
+ _id: string;
162
+ [key: string]: unknown;
163
+ }> : never;
164
+ /**
165
+ * Maps a full field record to its TypeScript output type.
166
+ * Required fields are non-nullable; optional fields are `T | null | undefined`.
167
+ *
168
+ * The `id` field is always `string` and always present.
169
+ * The `id` key from Fields is excluded to avoid duplication since
170
+ * we hardcode `{ id: string }` at the front.
171
+ */
172
+ type InferEntityDTO<Fields extends Record<string, FieldConfig>> = {
173
+ id: string;
174
+ } & { [K in keyof Fields as K extends 'id' ? never : Fields[K]['required'] extends true ? K : never]: FieldToTS<Fields[K]> } & { [K in keyof Fields as Fields[K]['required'] extends true ? never : K]?: FieldToTS<Fields[K]> | null };
175
+ /** Fields that are auto-generated and should not appear in create input. */
176
+ type AutoGeneratedFields = 'id' | 'createdAt' | 'updatedAt' | 'createdBy' | 'updatedBy' | '_version';
177
+ /**
178
+ * The input type for creating an entity.
179
+ * - Omits auto-generated fields (id, timestamps, version)
180
+ * - Required fields stay required; optional fields stay optional
181
+ */
182
+ type InferCreateInput<Fields extends Record<string, FieldConfig>> = Omit<{ [K in keyof Fields as K extends 'id' ? never : Fields[K]['required'] extends true ? K : never]: FieldToTS<Fields[K]> } & { [K in keyof Fields as Fields[K]['required'] extends true ? never : K]?: FieldToTS<Fields[K]> | null }, AutoGeneratedFields>;
183
+ /** Fields that cannot be changed after creation. */
184
+ type ImmutableFields = 'id' | 'createdAt' | 'createdBy';
185
+ type InferUpdateInput<Fields extends Record<string, FieldConfig>> = { [K in keyof Fields as K extends ImmutableFields ? never : K]?: FieldToTS<Fields[K]> | null };
186
+ //#endregion
202
187
  //#region src/behaviors/types.d.ts
188
+ /**
189
+ * Structural shape of a queue `JobDefinition` as seen by behaviors.
190
+ *
191
+ * The entity package CANNOT import `@murumets-ee/queue` (would form a
192
+ * cycle through `@murumets-ee/core`'s registry — see CLAUDE.md
193
+ * "Package boundaries"). Instead, behaviors receive an `EnqueueOnCommit`
194
+ * function via `BehaviorContext`, and the queue's `JobDefinition<T>`
195
+ * is structurally compatible with this minimal shape. The actual
196
+ * payload validation runs inside the queue when the wrapped resolver
197
+ * forwards the call.
198
+ */
199
+ interface JobLike {
200
+ readonly name: string;
201
+ }
202
+ /**
203
+ * Resolver shape for `BehaviorContext.enqueueOnCommit`. Synchronous,
204
+ * fire-and-forget — the resolver writes the row in the background and
205
+ * logs failures; behaviors do NOT await.
206
+ *
207
+ * Intended semantics (PLAN-OUTBOX §4.2): the row INSERT participates
208
+ * in the AdminClient operation's transaction — commit makes the job
209
+ * visible to the worker, rollback removes it. As of PR D this is the
210
+ * actual semantics: AdminClient threads its tx into the
211
+ * `EnqueueOnCommitFactory` once per call and passes the result through
212
+ * to behaviors. When the queue plugin is not loaded the field is
213
+ * `undefined` and behaviors no-op (per §6 Q6).
214
+ */
215
+ type EnqueueOnCommit = (job: JobLike, payload: unknown) => void;
216
+ /**
217
+ * Factory that returns an {@link EnqueueOnCommit} resolver bound to an
218
+ * optional Drizzle transaction handle. AdminClient invokes this once
219
+ * per CRUD operation, passing the active tx so behaviors firing inside
220
+ * the operation enqueue rows that commit (or roll back) atomically
221
+ * with the entity write.
222
+ *
223
+ * The `tx` parameter is intentionally typed as `unknown` here — the
224
+ * entity package CANNOT import `drizzle-orm/postgres-js` types without
225
+ * pulling in the database stack and breaking its leaf-package contract.
226
+ * The queue plugin (which writes the slot) and AdminClient (which
227
+ * supplies the tx) both work with the precise `PostgresJsDatabase`
228
+ * shape; the interior of the factory casts as needed.
229
+ *
230
+ * **Mirror definition** lives in
231
+ * `packages/queue/src/enqueue-on-commit-slot.ts` with `tx?:
232
+ * PostgresJsDatabase`. The two are structurally compatible by design;
233
+ * if you rename or change one, change both. The runtime contract is
234
+ * the same — only the type-time visibility of the parameter differs.
235
+ */
236
+ type EnqueueOnCommitFactory = (tx?: unknown) => EnqueueOnCommit;
203
237
  /**
204
238
  * Context passed to behavior hooks. Resolved once per request by AdminClient
205
239
  * from its `contextResolver` and forwarded into every hook so behaviors never
206
240
  * have to reach into AsyncLocalStorage themselves — that pattern breaks under
207
241
  * bundlers (e.g. Turbopack) that duplicate module instances across boundaries.
242
+ *
243
+ * `loadCurrent` returns the *pre-update* entity row. It is eagerly loaded
244
+ * by AdminClient before any hooks fire on the **update** codepath and
245
+ * cached for the rest of that call — so calling it from `beforeUpdate`
246
+ * or `afterUpdate` returns the SAME snapshot regardless of whether a
247
+ * sibling hook touched it. Returns `null` when no entity matches the id
248
+ * (rare — usually means the row was deleted concurrently). On `create`
249
+ * AND `delete` codepaths, `loadCurrent` is undefined: create has no
250
+ * pre-state, and delete hooks receive the entity id directly via their
251
+ * first argument so a separate snapshot is unnecessary.
252
+ *
253
+ * `afterUpdate` always receives the post-update row as its first argument,
254
+ * so the (`row`, `loadCurrent()`) pair gives hooks a complete (after, before)
255
+ * view without per-call Map state. The eager load costs one extra
256
+ * `findById` per update; updates are not a hot path in this codebase, and
257
+ * the consistency win — no foot-gun for hook authors — is worth it.
258
+ *
259
+ * `viaInternal` is `true` when the hook is running on an `AdminClient.updateInternal`
260
+ * call (the trusted server-side path; public PATCH always sets it false).
261
+ * Hooks SHOULD treat this as informational only — the route layer has
262
+ * authorized the *capability*, but the hook is still responsible for
263
+ * enforcing structural invariants. Workflowable, for example, validates
264
+ * the `_workflowStatus` transition table on every update regardless of
265
+ * `viaInternal` so that even a route-layer bug cannot push the workflow
266
+ * row into an illegal state. Use `viaInternal` when a hook genuinely
267
+ * needs to differentiate (e.g. side effects that should only fire when
268
+ * a real user — not the seed loader — initiates the change).
269
+ *
270
+ * `enqueueOnCommit` is the outbox entry point for projection-style hooks
271
+ * (PR C of PLAN-OUTBOX). When wired (the queue plugin is available in
272
+ * the running app), behaviors can enqueue side-effect jobs without
273
+ * importing the queue package directly, keeping entity-as-leaf invariant
274
+ * intact. Synchronous + fire-and-forget — no await needed in hooks.
275
+ * Undefined when the AdminClient was constructed without an
276
+ * `enqueueOnCommit` resolver (e.g. CLI scripts that don't load the
277
+ * queue plugin); behaviors that depend on it must guard with `if
278
+ * (ctx.enqueueOnCommit) { ... }` or document the dependency loudly.
208
279
  */
209
280
  interface BehaviorContext {
210
281
  user?: {
@@ -212,6 +283,9 @@ interface BehaviorContext {
212
283
  name?: string;
213
284
  email?: string;
214
285
  };
286
+ loadCurrent?: () => Promise<Record<string, unknown> | null>;
287
+ viaInternal?: boolean;
288
+ enqueueOnCommit?: EnqueueOnCommit;
215
289
  }
216
290
  interface Behavior<F extends Record<string, FieldConfig> = {}> {
217
291
  name: string;
@@ -226,6 +300,78 @@ interface Behavior<F extends Record<string, FieldConfig> = {}> {
226
300
  };
227
301
  }
228
302
  //#endregion
303
+ //#region src/cursor.d.ts
304
+ /** Cursor input for keyset pagination. */
305
+ interface CursorInput {
306
+ /** Sort field name (e.g. 'createdAt'). Must be a real column on the entity. */
307
+ field: string;
308
+ /** Last seen value of the sort field. */
309
+ value: string | number;
310
+ /** Sort direction — must match the ORDER BY direction. */
311
+ direction: 'asc' | 'desc';
312
+ /** Tie-breaker: last seen entity ID. Required for non-unique sort fields. */
313
+ id?: string | undefined;
314
+ }
315
+ //#endregion
316
+ //#region src/admin-config.d.ts
317
+ /**
318
+ * Optional admin UI configuration for entities.
319
+ * Controls how entities appear in the admin sidebar, list pages, and forms.
320
+ */
321
+ interface EntityAdminConfig {
322
+ /** Sidebar section: 'content' | 'structure' | custom string. Default: 'content' */
323
+ group?: string;
324
+ /** Plural display name for sidebar + list pages. Default: title-cased pluralized entity name */
325
+ label?: string;
326
+ /** Singular label for "New X" button. Default: title-cased entity name */
327
+ labelSingular?: string;
328
+ /** Lucide icon name as string, e.g. 'file-text' */
329
+ icon?: string;
330
+ /** Description shown on list page */
331
+ description?: string;
332
+ /** Form layout. Default: 'single' */
333
+ layout?: 'single' | 'two-column';
334
+ /** Fields to hide in the form */
335
+ hiddenFields?: string[];
336
+ /** Columns to hide in the list */
337
+ hiddenColumns?: string[];
338
+ /** Per-field label/description/placeholder overrides */
339
+ fieldOverrides?: Record<string, {
340
+ label?: string;
341
+ description?: string;
342
+ placeholder?: string;
343
+ }>;
344
+ /** Default list sort field. Default: 'createdAt' */
345
+ defaultSort?: string;
346
+ /** Default list sort direction. Default: 'desc' */
347
+ defaultSortDirection?: 'asc' | 'desc';
348
+ /** List page size. Default: 20 */
349
+ pageSize?: number;
350
+ /** For block editor: fields to show in Puck root config */
351
+ rootFields?: string[];
352
+ /** Suppress "New" button in the list page */
353
+ disableCreate?: boolean;
354
+ /** Order within sidebar group. Default: 0 */
355
+ sortOrder?: number;
356
+ /**
357
+ * Hide the entity from BOTH dashboard and sidebar.
358
+ * Use for system entities that should not appear in any UI.
359
+ * Shorthand for `hideFromMenu: true` + `hideFromDashboard: true`.
360
+ */
361
+ hidden?: boolean;
362
+ /**
363
+ * Hide from sidebar menu only. Entity remains accessible via dashboard, direct URL,
364
+ * and the entity API. Use for storage models accessed through specialized UI
365
+ * (e.g., Ticket entities accessed via the inbox/board, not as a CRUD table).
366
+ */
367
+ hideFromMenu?: boolean;
368
+ /**
369
+ * Hide from dashboard widget grid only. Entity still appears in sidebar.
370
+ * Use for entities that don't have meaningful counts/stats to show.
371
+ */
372
+ hideFromDashboard?: boolean;
373
+ }
374
+ //#endregion
229
375
  //#region src/define-entity.d.ts
230
376
  /**
231
377
  * A fully resolved entity with merged behavior fields.
@@ -277,46 +423,6 @@ interface SecurityContext {
277
423
  */
278
424
  type ContextResolver = () => SecurityContext | undefined | Promise<SecurityContext | undefined>;
279
425
  //#endregion
280
- //#region src/types/infer.d.ts
281
- /**
282
- * Recursive JSON-compatible value, for `field.json()` (jsonb column).
283
- * Mirrors what Postgres jsonb can store: primitives, arrays, or objects.
284
- */
285
- type JsonValue = string | number | boolean | null | JsonValue[] | {
286
- [key: string]: JsonValue;
287
- };
288
- /**
289
- * Maps a single FieldConfig to its TypeScript output type.
290
- * Each branch is a shallow comparison — no recursion.
291
- */
292
- type FieldToTS<F extends FieldConfig> = F extends IdField ? string : F extends TextField ? string : F extends NumberField ? number : F extends BooleanField ? boolean : F extends DateField ? Date | string : F extends SelectField ? F['options'][number] : F extends ReferenceField ? F['cardinality'] extends 'many' ? string[] : string : F extends MediaField ? string : F extends RichTextField ? Record<string, unknown>[] : F extends SlugField ? string : F extends JsonField ? JsonValue : F extends BlocksField ? Array<{
293
- _block: string;
294
- _id: string;
295
- [key: string]: unknown;
296
- }> : never;
297
- /**
298
- * Maps a full field record to its TypeScript output type.
299
- * Required fields are non-nullable; optional fields are `T | null | undefined`.
300
- *
301
- * The `id` field is always `string` and always present.
302
- * The `id` key from Fields is excluded to avoid duplication since
303
- * we hardcode `{ id: string }` at the front.
304
- */
305
- type InferEntityDTO<Fields extends Record<string, FieldConfig>> = {
306
- id: string;
307
- } & { [K in keyof Fields as K extends 'id' ? never : Fields[K]['required'] extends true ? K : never]: FieldToTS<Fields[K]> } & { [K in keyof Fields as Fields[K]['required'] extends true ? never : K]?: FieldToTS<Fields[K]> | null };
308
- /** Fields that are auto-generated and should not appear in create input. */
309
- type AutoGeneratedFields = 'id' | 'createdAt' | 'updatedAt' | 'createdBy' | 'updatedBy' | '_version';
310
- /**
311
- * The input type for creating an entity.
312
- * - Omits auto-generated fields (id, timestamps, version)
313
- * - Required fields stay required; optional fields stay optional
314
- */
315
- type InferCreateInput<Fields extends Record<string, FieldConfig>> = Omit<{ [K in keyof Fields as K extends 'id' ? never : Fields[K]['required'] extends true ? K : never]: FieldToTS<Fields[K]> } & { [K in keyof Fields as Fields[K]['required'] extends true ? never : K]?: FieldToTS<Fields[K]> | null }, AutoGeneratedFields>;
316
- /** Fields that cannot be changed after creation. */
317
- type ImmutableFields = 'id' | 'createdAt' | 'createdBy';
318
- type InferUpdateInput<Fields extends Record<string, FieldConfig>> = { [K in keyof Fields as K extends ImmutableFields ? never : K]?: FieldToTS<Fields[K]> | null };
319
- //#endregion
320
426
  //#region src/types/logger.d.ts
321
427
  /**
322
428
  * Minimal logger interface compatible with Pino.
@@ -343,32 +449,64 @@ type EntityResolver = () => Map<string, Entity> | undefined;
343
449
  interface AdminClientConfig<AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>> {
344
450
  entity: Entity<AllFields>;
345
451
  db: PostgresJsDatabase;
346
- logger?: Logger;
452
+ logger?: Logger | undefined;
347
453
  /** Optional count cache for COUNT(*) query optimization. */
348
- countCache?: CountCacheLike;
454
+ countCache?: CountCacheLike | undefined;
349
455
  /**
350
456
  * Resolves the current request's security context (user, role checker, scope).
351
457
  * Provided automatically by `createAdminClient()` from @murumets-ee/core/clients.
352
458
  * For direct `new AdminClient()` usage, pass your own resolver or use `runAsCli()`.
353
459
  */
354
- contextResolver?: ContextResolver;
460
+ contextResolver?: ContextResolver | undefined;
355
461
  /**
356
462
  * Resolves the running app's entity registry. Used by cascade delete to
357
463
  * read each referencing field's `onDelete` strategy. When omitted, all
358
464
  * incoming references are treated as `restrict` — a safe default that
359
465
  * blocks deletes rather than guessing.
360
466
  */
361
- entityResolver?: EntityResolver;
467
+ entityResolver?: EntityResolver | undefined;
468
+ /**
469
+ * Outbox entry point exposed on `BehaviorContext.enqueueOnCommit`
470
+ * (PR C of PLAN-OUTBOX). When provided, projection-style behaviors can
471
+ * fire side-effect jobs from their hooks without importing the queue
472
+ * package — the entity package stays a leaf in the dependency graph.
473
+ *
474
+ * Used as a fallback when `enqueueOnCommitFactory` is not supplied:
475
+ * AdminClient passes this resolver verbatim into hook contexts, and
476
+ * the row INSERT is NOT bound to the operation's transaction. Tests
477
+ * that need to capture call-site behavior without wiring queue can
478
+ * pass this directly.
479
+ *
480
+ * In production wiring (`createAdminClient` in
481
+ * `@murumets-ee/core/clients`), prefer `enqueueOnCommitFactory` —
482
+ * that's the form that delivers PLAN-OUTBOX §4.2's "rollback removes
483
+ * the job" guarantee.
484
+ */
485
+ enqueueOnCommit?: BehaviorContext['enqueueOnCommit'] | undefined;
486
+ /**
487
+ * Outbox factory exposed on `BehaviorContext.enqueueOnCommit` (PR D
488
+ * of PLAN-OUTBOX). When provided, AdminClient invokes the factory
489
+ * once per CRUD operation with the active Drizzle transaction so the
490
+ * resulting fire-and-forget resolver routes its INSERT through that
491
+ * tx. Hook-fired enqueues commit (or roll back) atomically with the
492
+ * entity write — the canonical PLAN-OUTBOX §4.2 semantics.
493
+ *
494
+ * Wired by `createAdminClient()` in `@murumets-ee/core/clients`
495
+ * against the running app's queue plugin. Takes precedence over the
496
+ * older `enqueueOnCommit` field when both are set; CLI scripts that
497
+ * don't load the queue plugin omit both.
498
+ */
499
+ enqueueOnCommitFactory?: EnqueueOnCommitFactory | undefined;
362
500
  }
363
501
  interface FindManyOptions {
364
502
  where?: SQL | undefined;
365
- limit?: number;
366
- offset?: number;
367
- orderBy?: SQL | SQL[];
368
- locale?: string;
503
+ limit?: number | undefined;
504
+ offset?: number | undefined;
505
+ orderBy?: SQL | SQL[] | undefined;
506
+ locale?: string | undefined;
369
507
  /** Default content locale. For localized blocks, NULL rows (from initial create)
370
508
  * are only returned as fallback when locale matches defaultLocale. */
371
- defaultLocale?: string;
509
+ defaultLocale?: string | undefined;
372
510
  /**
373
511
  * Cursor-based (keyset) pagination. When provided, replaces OFFSET with a
374
512
  * WHERE condition for O(1) page access at any depth. The `offset` option
@@ -376,7 +514,13 @@ interface FindManyOptions {
376
514
  *
377
515
  * The cursor `field` must be a real column on the entity table.
378
516
  */
379
- cursor?: CursorInput;
517
+ cursor?: CursorInput | undefined;
518
+ /**
519
+ * Include fields marked `internal: true` (and legacy `_`-prefixed
520
+ * infrastructure columns) in the returned DTOs. Use only on trusted
521
+ * server-side reads that need to inspect state-machine fields.
522
+ */
523
+ includeInternal?: boolean | undefined;
380
524
  }
381
525
  interface CountOptions {
382
526
  where?: SQL | undefined;
@@ -406,17 +550,84 @@ declare class AdminClient<AllFields extends Record<string, FieldConfig> = Record
406
550
  private entity;
407
551
  private db;
408
552
  private logger?;
553
+ /** Public-surface create schema — internal fields excluded. */
409
554
  private createSchema;
555
+ /** Public-surface update schema — internal fields excluded. */
410
556
  private updateSchema;
557
+ /** Trusted-surface update schema — internal fields included. Used by `updateInternal()`. */
558
+ private updateInternalSchema;
559
+ /** Names of fields marked `internal: true`. Cached for O(1) strip / pick. */
560
+ private internalFieldNames;
411
561
  private table;
412
562
  private countCache?;
413
563
  private contextResolver?;
414
564
  private entityResolver?;
415
- /** Shared context for entity-data-ops functions. */
565
+ private enqueueOnCommit?;
566
+ private enqueueOnCommitFactory?;
567
+ /** Shared context for entity-data-ops functions, bound to the default db. */
416
568
  private get ctx();
569
+ /**
570
+ * Build an {@link EntityContext} bound to a specific Drizzle executor.
571
+ *
572
+ * Used by the public CRUD methods so that when the caller threads in
573
+ * `{ tx }` (PR D of PLAN-OUTBOX) every block-load / translation
574
+ * merge / scope-condition lookup hits the SAME tx as the entity write.
575
+ * Without this, a `loadBlocks(this.ctx, ...)` call inside an active tx
576
+ * would query `this.db` (the AdminClient's owned connection) and miss
577
+ * the in-flight INSERT — `entity_refs` / blocks rows wouldn't be
578
+ * visible until commit.
579
+ *
580
+ * For callers that don't pass a tx, `exec === this.db` and the ctx
581
+ * matches today's behaviour exactly.
582
+ */
583
+ private ctxFor;
584
+ /**
585
+ * Build the `enqueueOnCommit` slot of a {@link BehaviorContext} for
586
+ * the active tx, ready to spread into `resolveAuthContext`'s options
587
+ * (PR D of PLAN-OUTBOX).
588
+ *
589
+ * - When the queue plugin's factory is wired, invoke it with `tx` so
590
+ * the resulting fire-and-forget resolver routes its INSERT through
591
+ * the caller's transaction (atomic with the entity write).
592
+ * - When only the legacy static `enqueueOnCommit` option is set, pass
593
+ * it through verbatim — used by tests and callers that wired their
594
+ * own resolver directly.
595
+ * - When neither is configured (CLI scripts, no queue plugin), return
596
+ * `{}` so the BehaviorContext's `enqueueOnCommit` field stays
597
+ * undefined and behaviors guard accordingly.
598
+ */
599
+ private buildAuthOptions;
600
+ /**
601
+ * Re-bind `behaviorCtx.enqueueOnCommit` to the supplied executor.
602
+ *
603
+ * Used by `delete` / `deleteMany` when they open an internal tx
604
+ * because no caller tx was supplied: the auth context was resolved
605
+ * BEFORE the tx existed, so its `enqueueOnCommit` (if a factory is
606
+ * wired) was bound to `undefined` and would commit the queue row
607
+ * outside the internal tx. Rebinding here ensures the queue write
608
+ * rolls back with the cascade if anything in `runDelete` throws.
609
+ *
610
+ * Spreads conditionally to satisfy `exactOptionalPropertyTypes` —
611
+ * never assigns `enqueueOnCommit: undefined` (which would clear an
612
+ * existing static resolver if the factory wasn't configured).
613
+ */
614
+ private rebindEnqueueOnCommit;
417
615
  constructor(config: AdminClientConfig<AllFields>);
418
616
  /**
419
- * Create a new entity
617
+ * Strip caller-provided values for internal fields from the input.
618
+ *
619
+ * Returns a NEW object — does not mutate the caller's input. Hooks run
620
+ * AFTER this strip, so they can still set internal fields legitimately;
621
+ * those values are then preserved through validation by `pickInternalFields`.
622
+ */
623
+ private stripCallerInternals;
624
+ /**
625
+ * Pick the internal-field values that hooks set on `data`.
626
+ * Used to re-attach them after schema validation strips unknown keys.
627
+ */
628
+ private pickInternalFields;
629
+ /**
630
+ * Create a new entity.
420
631
  *
421
632
  * Flow:
422
633
  * 1. Validate input with Zod
@@ -425,14 +636,37 @@ declare class AdminClient<AllFields extends Record<string, FieldConfig> = Record
425
636
  * 4. Insert into database
426
637
  * 5. Execute afterCreate hooks
427
638
  * 6. Shape DTO and return
639
+ *
640
+ * **Caller transaction (`options.tx`)** — PR D of PLAN-OUTBOX. When
641
+ * supplied, the row INSERT, blocks save, refs sync, and any hook-fired
642
+ * `enqueueOnCommit` participate in the caller's transaction:
643
+ *
644
+ * ```ts
645
+ * await db.transaction(async (tx) => {
646
+ * const order = await orders.create({ ... }, { tx })
647
+ * // afterCreate hooks fired with this tx — enqueueOnCommit's INSERT
648
+ * // routes through it. Outer rollback removes both atomically.
649
+ * })
650
+ * ```
651
+ *
652
+ * When omitted, behaviour matches pre-PR-D exactly: each statement
653
+ * auto-commits, hooks run after commit, and the static
654
+ * `enqueueOnCommit` resolver (if any) writes outside the entity tx.
428
655
  */
429
- create(data: InferCreateInput<AllFields>): Promise<InferEntityDTO<AllFields>>;
656
+ create(data: InferCreateInput<AllFields>, options?: {
657
+ tx?: PostgresJsDatabase;
658
+ }): Promise<InferEntityDTO<AllFields>>;
430
659
  /**
431
- * Find entity by ID
660
+ * Find entity by ID.
661
+ *
662
+ * Pass `includeInternal: true` from trusted server code (workflow
663
+ * transitions, behavior implementations) when you need to read fields
664
+ * marked `internal: true` — by default they are stripped from the DTO.
432
665
  */
433
666
  findById(id: string, options?: {
434
667
  locale?: string;
435
668
  defaultLocale?: string;
669
+ includeInternal?: boolean;
436
670
  }): Promise<InferEntityDTO<AllFields> | null>;
437
671
  /**
438
672
  * Find multiple entities
@@ -455,17 +689,65 @@ declare class AdminClient<AllFields extends Record<string, FieldConfig> = Record
455
689
  */
456
690
  update(id: string, data: InferUpdateInput<AllFields>, options?: {
457
691
  locale?: string;
692
+ tx?: PostgresJsDatabase;
693
+ }): Promise<InferEntityDTO<AllFields>>;
694
+ /**
695
+ * Update entity by ID, allowing writes to fields marked `internal: true`.
696
+ *
697
+ * Use this from trusted server code that has already authorized the
698
+ * transition out-of-band — typical example: workflow transitions invoked
699
+ * from an admin route that has already checked the `publish` permission.
700
+ * The HTTP `PATCH` surface uses the public {@link update} method, which
701
+ * silently strips internal fields so untrusted callers cannot poison
702
+ * state-machine values like `_workflowStatus`.
703
+ *
704
+ * The validation schema for this method INCLUDES internal fields — values
705
+ * are still type-checked and constrained (e.g. select-field options).
706
+ *
707
+ * **Security**: never call this from a code path that forwards request body
708
+ * fields directly. The caller must construct the internal-field values
709
+ * server-side from authorized state transitions.
710
+ */
711
+ updateInternal(id: string, data: Record<string, unknown>, options?: {
712
+ locale?: string;
713
+ tx?: PostgresJsDatabase;
458
714
  }): Promise<InferEntityDTO<AllFields>>;
715
+ private updateImpl;
716
+ /**
717
+ * Internal `findById` variant that issues its SELECT through the
718
+ * caller-supplied executor. Used by `updateImpl` so the pre-update
719
+ * snapshot reflects any earlier writes the caller already made
720
+ * inside the same transaction.
721
+ *
722
+ * **Scope is NOT optional** — it must be the same `scopeId` that
723
+ * `resolveAuthContext` produced for the surrounding `update` call.
724
+ * The eventual UPDATE is scope-gated via its WHERE clause; if this
725
+ * pre-load skipped scope, hooks with side effects (audit logs,
726
+ * system-message creation, outbox enqueues) would observe and
727
+ * dispatch on cross-tenant rows even when the gated UPDATE matches
728
+ * nothing. See HIGH finding in pr-review for #247.
729
+ *
730
+ * Permission check is skipped because `resolveAuthContext` ran first
731
+ * in the surrounding `update` call.
732
+ */
733
+ private findByIdInternal;
459
734
  /**
460
735
  * Delete entity by ID.
461
736
  *
462
737
  * Wraps cascade + delete in a transaction so cascaded deletes are rolled
463
- * back if the final entity delete fails (no partial data loss).
738
+ * back if the final entity delete fails (no partial data loss). When
739
+ * the caller threads in `{ tx }` (PR D of PLAN-OUTBOX), the same tx
740
+ * is reused — the inner `db.transaction(...)` call is replaced with a
741
+ * direct invocation of the body, and any hook-fired `enqueueOnCommit`
742
+ * INSERT routes through the caller's tx so an outer rollback removes
743
+ * everything atomically.
464
744
  *
465
745
  * Checks entity_refs for incoming references first — throws
466
746
  * ReferencedEntityError if this entity is still used somewhere.
467
747
  */
468
- delete(id: string): Promise<void>;
748
+ delete(id: string, options?: {
749
+ tx?: PostgresJsDatabase;
750
+ }): Promise<void>;
469
751
  /**
470
752
  * Delete multiple entities matching a WHERE condition.
471
753
  *
@@ -481,7 +763,9 @@ declare class AdminClient<AllFields extends Record<string, FieldConfig> = Record
481
763
  *
482
764
  * @returns Number of rows deleted
483
765
  */
484
- deleteMany(where: SQL): Promise<number>;
766
+ deleteMany(where: SQL, options?: {
767
+ tx?: PostgresJsDatabase;
768
+ }): Promise<number>;
485
769
  /** Maximum cascade depth to prevent infinite recursion from circular references. */
486
770
  private static readonly MAX_CASCADE_DEPTH;
487
771
  /**
@@ -545,6 +829,8 @@ declare class AdminClient<AllFields extends Record<string, FieldConfig> = Record
545
829
  */
546
830
  updateMany(where: SQL, data: Partial<InferUpdateInput<AllFields>>, options?: {
547
831
  expressions?: Record<string, SQL>;
832
+ allowInternal?: boolean;
833
+ tx?: PostgresJsDatabase;
548
834
  }): Promise<number>;
549
835
  /**
550
836
  * Run an aggregate query on this entity's table.
@@ -618,30 +904,66 @@ declare class AdminClient<AllFields extends Record<string, FieldConfig> = Record
618
904
  /**
619
905
  * Save translation for an entity.
620
906
  * Each translatable field is a real column on the translation table.
907
+ *
908
+ * **Caller transaction (`options.tx`)** — Phase 4b of PLAN-OUTBOX. When
909
+ * supplied, the upsert participates in the caller's transaction so a
910
+ * caller wrapping `entity.create + saveTranslation` in a `db.transaction`
911
+ * gets atomic visibility — outer rollback removes BOTH the entity row
912
+ * and the translation row, never leaves an orphan translation pointing
913
+ * at a now-deleted entity. Without it, the translation would auto-commit
914
+ * regardless of the outer rollback. No hooks fire on this method, so no
915
+ * factory plumbing is needed — just routing the SQL through `exec`.
621
916
  */
622
- saveTranslation(entityId: string, locale: string, translations: Record<string, unknown>): Promise<void>;
917
+ saveTranslation(entityId: string, locale: string, translations: Record<string, unknown>, options?: {
918
+ tx?: PostgresJsDatabase;
919
+ }): Promise<void>;
623
920
  /**
624
- * PHASE 3: Delete translation(s) for an entity
625
- * If locale is provided, deletes specific locale; otherwise deletes all translations
921
+ * Delete translation(s) for an entity. If `locale` is provided, deletes
922
+ * the specific locale row; otherwise deletes every translation row for
923
+ * the entity.
924
+ *
925
+ * **Caller transaction (`options.tx`)** — Phase 4b of PLAN-OUTBOX. Same
926
+ * shape as `saveTranslation` — when supplied, the DELETE participates
927
+ * in the caller's tx.
626
928
  */
627
- deleteTranslation(entityId: string, locale?: string): Promise<void>;
929
+ deleteTranslation(entityId: string, locale?: string, options?: {
930
+ tx?: PostgresJsDatabase;
931
+ }): Promise<void>;
628
932
  /**
629
933
  * Save block-level translations for an entity.
630
934
  * For each block in each blocks field, extracts translatable field values
631
935
  * and upserts them into {entity}_layout_translations.
632
936
  *
633
937
  * Block _id must match an existing layout row ID.
938
+ *
939
+ * **Caller transaction (`options.tx`)** — Phase 4b of PLAN-OUTBOX.
940
+ * Routes the layout-row lookup AND every layout-translation upsert
941
+ * through the supplied tx, so a caller doing
942
+ * `entity.update + saveBlockTranslations` in one tx gets atomic
943
+ * visibility on rollback.
634
944
  */
635
- saveBlockTranslations(entityId: string, locale: string, data: Record<string, unknown>): Promise<void>;
945
+ saveBlockTranslations(entityId: string, locale: string, data: Record<string, unknown>, options?: {
946
+ tx?: PostgresJsDatabase;
947
+ }): Promise<void>;
636
948
  /**
637
949
  * Save per-locale blocks for an entity.
638
950
  * Used for entities with `localized: true` on their blocks field — each locale gets
639
951
  * its own independent block layout rows in the layout table.
952
+ *
953
+ * **Caller transaction (`options.tx`)** — Phase 4b of PLAN-OUTBOX. Threaded
954
+ * through to `saveBlocks`, which already accepts an executor.
640
955
  */
641
- saveLocalizedBlocks(entityId: string, locale: string, data: Record<string, unknown>): Promise<void>;
956
+ saveLocalizedBlocks(entityId: string, locale: string, data: Record<string, unknown>, options?: {
957
+ tx?: PostgresJsDatabase;
958
+ }): Promise<void>;
642
959
  /**
643
960
  * Save blocks for an entity after create/update.
644
961
  * For each blocks field, writes rows to {entity}_layout table.
962
+ *
963
+ * `exec` is the active Drizzle executor — either a caller-supplied tx
964
+ * (PR D of PLAN-OUTBOX) or `this.db`. Routing through it keeps the
965
+ * blocks INSERTs/DELETEs in the same transaction as the parent
966
+ * entity write.
645
967
  */
646
968
  private saveBlocks;
647
969
  /**
@@ -651,13 +973,27 @@ declare class AdminClient<AllFields extends Record<string, FieldConfig> = Record
651
973
  * - 'update': deletes refs for changed ref-bearing fields, then inserts new ones
652
974
  *
653
975
  * Gracefully skips if entity_refs table is not registered (e.g. before migration).
976
+ *
977
+ * `exec` is the active Drizzle executor — caller-supplied tx (PR D of
978
+ * PLAN-OUTBOX) or `this.db`. Routes the entity_refs UPDATE/INSERT
979
+ * through the same transaction as the parent entity write.
654
980
  */
655
981
  private syncRefs;
656
982
  /**
657
983
  * Invalidate all count cache entries for this entity.
984
+ *
985
+ * **Tx-aware** (PR D of PLAN-OUTBOX). When `inCallerTx === true`, we
986
+ * skip the invalidation: the caller's tx hasn't committed yet, and
987
+ * eagerly invalidating would have other connections re-compute the
988
+ * count from the still-pre-tx state and cache that stale value.
989
+ * The caller is responsible for invalidating after their own commit
990
+ * (or accepting the cache TTL self-heal). For internally-managed
991
+ * txs (no-caller-tx path) and no-tx CRUD calls, the invalidation
992
+ * runs after the inner `db.transaction(...)` resolves — which only
993
+ * happens post-commit — so the cache reflects the committed state.
658
994
  */
659
995
  private invalidateCountCache;
660
996
  }
661
997
  //#endregion
662
- export { AdminClient, type AdminClientConfig, type CountCacheLike, type CountOptions, type FindManyOptions };
998
+ export { AdminClient, type AdminClientConfig, type CountCacheLike, type CountOptions, type FindManyOptions, type InferCreateInput, type InferEntityDTO, type InferUpdateInput };
663
999
  //# sourceMappingURL=index.d.mts.map