@rebasepro/agent-skills 0.8.0 → 0.9.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.
@@ -17,7 +17,7 @@ Rebase implements a **multi-layered, defense-in-depth** security architecture. S
17
17
  - [Layer 2: API Key Permission Guard](#layer-2-api-key-permission-guard)
18
18
  - [Layer 3: DataHooks (API Boundary)](#layer-3-datahooks-api-boundary)
19
19
  - [Layer 4: Scoped DataDriver](#layer-4-scoped-datadriver)
20
- - [Layer 5: Entity Callbacks](#layer-5-entity-callbacks)
20
+ - [Layer 5: Collection Callbacks](#layer-5-collection-callbacks)
21
21
  - [Fail-Closed Design](#fail-closed-design)
22
22
  - [Securing Without Database RLS](#securing-without-database-rls)
23
23
  - [Common Security Patterns](#common-security-patterns)
@@ -61,7 +61,7 @@ Every request — REST, GraphQL, and WebSocket — passes through **5 security l
61
61
  └──────────────┬───────────────────────────────────────┘
62
62
 
63
63
  ┌──────────────▼───────────────────────────────────────┐
64
- │ Layer 5: Entity Callbacks (Per-Collection)
64
+ │ Layer 5: Collection Callbacks (Per-Collection)
65
65
  │ beforeSave / afterRead / beforeDelete │
66
66
  │ Per-collection hooks inside the DataDriver │
67
67
  └──────────────────────────────────────────────────────┘
@@ -77,12 +77,12 @@ All three protocols share the same security middleware stack:
77
77
 
78
78
  ### REST
79
79
  ```
80
- HTTP Request → Auth Middleware → API Key Guard → DataHooks → Scoped Driver → Entity Callbacks → Response
80
+ HTTP Request → Auth Middleware → API Key Guard → DataHooks → Scoped Driver → Collection Callbacks → Response
81
81
  ```
82
82
 
83
83
  ### GraphQL
84
84
  ```
85
- GraphQL Request → Auth Middleware → API Key Guard → Scoped Driver → Entity Callbacks → Response
85
+ GraphQL Request → Auth Middleware → API Key Guard → Scoped Driver → Collection Callbacks → Response
86
86
  ```
87
87
  GraphQL shares the same Hono middleware chain as REST. Resolvers extract the scoped driver from context and throw if unavailable.
88
88
 
@@ -136,13 +136,13 @@ export async function scopeDataDriver(
136
136
  | Anonymous (`requireAuth: false`) | `"anon"` | `["anon"]` | RLS with anonymous identity |
137
137
  | No token + `requireAuth: true` | — | — | **Rejected (401)** |
138
138
 
139
- > **IMPORTANT FOR AGENTS:** These are **reserved system identity values** that the middleware injects automatically. When writing DataHooks or Entity Callbacks, developers should use these identities to gate behavior:
139
+ > **IMPORTANT FOR AGENTS:** These are **reserved system identity values** that the middleware injects automatically. When writing DataHooks or Collection Callbacks, developers should use these identities to gate behavior:
140
140
  > - `uid: "service"` + `roles: ["admin"]` — Server-side `rebase.data` calls (cron jobs, custom functions using `rebase.data`, webhooks). These go through the full middleware pipeline authenticated with the service key.
141
- > - `uid: "anon"` + `roles: ["anon"]` — Unauthenticated requests when `requireAuth: false`. **Note:** for anonymous REST requests, `context.user` in Entity Callbacks may be `undefined`; only the DataDriver is scoped with the anon identity. For WebSocket connections, a full `User` object with `uid: "anon"` is provided.
141
+ > - `uid: "anon"` + `roles: ["anon"]` — Unauthenticated requests when `requireAuth: false`. **Note:** for anonymous REST requests, `context.user` in Collection Callbacks may be `undefined`; only the DataDriver is scoped with the anon identity. For WebSocket connections, a full `User` object with `uid: "anon"` is provided.
142
142
  > - `uid: "api-key:{id}"` + `roles: ["service"]` (or `["admin", "service"]`) — API key requests.
143
143
  > - Real user IDs and roles for JWT-authenticated requests.
144
144
  >
145
- > **Key insight:** `rebase.data` (the server-side singleton) is NOT a raw admin driver — it round-trips through the REST API using the service key, so all DataHooks and Entity Callbacks fire with `uid: "service"`, `roles: ["admin"]`. This means callbacks can distinguish server-internal reads from end-user reads by checking `context.user?.roles?.includes("admin")`.
145
+ > **Key insight:** `rebase.data` (the server-side singleton) is NOT a raw admin driver — it round-trips through the REST API using the service key, so all DataHooks and Collection Callbacks fire with `uid: "service"`, `roles: ["admin"]`. This means callbacks can distinguish server-internal reads from end-user reads by checking `context.user?.roles?.includes("admin")`.
146
146
 
147
147
  ---
148
148
 
@@ -247,9 +247,9 @@ interface BackendHookContext {
247
247
 
248
248
  ### Execution Order
249
249
 
250
- DataHooks run **after** per-collection `EntityCallbacks` (which execute inside the DataDriver, closer to the database) and **before** the API response is sent to the client. This gives you two opportunities to enforce security:
250
+ DataHooks run **after** per-collection `CollectionCallbacks` (which execute inside the DataDriver, closer to the database) and **before** the API response is sent to the client. This gives you two opportunities to enforce security:
251
251
 
252
- 1. **EntityCallbacks** — per-collection, inside the driver
252
+ 1. **CollectionCallbacks** — per-collection, inside the driver
253
253
  2. **DataHooks** — cross-cutting, at the API boundary
254
254
 
255
255
  ---
@@ -271,12 +271,12 @@ PostgreSQL RLS policies use `auth.uid()`, `auth.roles()`, and `auth.jwt()` to re
271
271
 
272
272
  ---
273
273
 
274
- ## Layer 5: Entity Callbacks
274
+ ## Layer 5: Collection Callbacks
275
275
 
276
- Entity callbacks are per-collection lifecycle hooks that run **inside** the DataDriver, close to the database. They provide collection-specific security enforcement:
276
+ Collection callbacks are per-collection lifecycle hooks that run **inside** the DataDriver, close to the database. They provide collection-specific security enforcement:
277
277
 
278
278
  ```typescript
279
- const ordersCollection: PostgresCollection = {
279
+ const ordersCollection: PostgresCollectionConfig = {
280
280
  name: "Orders",
281
281
  slug: "orders",
282
282
  table: "orders",
@@ -289,9 +289,9 @@ const ordersCollection: PostgresCollection = {
289
289
  }
290
290
  return values;
291
291
  },
292
- beforeDelete: async ({ entity, context }) => {
292
+ beforeDelete: async ({ row, context }) => {
293
293
  // Prevent deletion of fulfilled orders
294
- if (entity.values.status === "fulfilled") {
294
+ if (row.status === "fulfilled") {
295
295
  throw new Error("Cannot delete fulfilled orders");
296
296
  }
297
297
  },
@@ -300,7 +300,7 @@ const ordersCollection: PostgresCollection = {
300
300
  };
301
301
  ```
302
302
 
303
- For full documentation on entity callbacks, see the `rebase-collections` skill.
303
+ For full documentation on collection callbacks, see the `rebase-collections` skill.
304
304
 
305
305
  ---
306
306
 
@@ -327,7 +327,7 @@ Rebase follows a **fail-closed** security model throughout the stack:
327
327
 
328
328
  ## Securing Without Database RLS
329
329
 
330
- If you cannot modify database-level RLS policies — or your database doesn't support them — use **DataHooks** and **Entity Callbacks** to enforce security entirely at the application level.
330
+ If you cannot modify database-level RLS policies — or your database doesn't support them — use **DataHooks** and **Collection Callbacks** to enforce security entirely at the application level.
331
331
 
332
332
  ### Strategy: DataHooks as Your Security Layer
333
333
 
@@ -384,7 +384,7 @@ const hooks: BackendHooks = {
384
384
 
385
385
  if (!user.roles.includes("admin")) {
386
386
  // For non-admins, you may need to fetch the entity first
387
- // to verify ownership. Use Entity Callbacks for this pattern
387
+ // to verify ownership. Use Collection Callbacks for this pattern
388
388
  // since they receive the full entity.
389
389
  throw ApiError.forbidden("Only admins can delete records");
390
390
  }
@@ -393,40 +393,40 @@ const hooks: BackendHooks = {
393
393
  };
394
394
  ```
395
395
 
396
- ### Strategy: Entity Callbacks for Ownership Checks
396
+ ### Strategy: Collection Callbacks for Ownership Checks
397
397
 
398
- Entity Callbacks receive the full entity data, making them ideal for ownership verification on deletes and updates:
398
+ Collection Callbacks receive the full entity data, making them ideal for ownership verification on deletes and updates:
399
399
 
400
400
  ```typescript
401
- const ordersCollection: PostgresCollection = {
401
+ const ordersCollection: PostgresCollectionConfig = {
402
402
  name: "Orders",
403
403
  slug: "orders",
404
404
  table: "orders",
405
405
  callbacks: {
406
- beforeSave: async ({ values, entityId, context, previousValues }) => {
406
+ beforeSave: async ({ values, id, context, previousValues }) => {
407
407
  const user = context.user;
408
408
  if (!user) throw new Error("Unauthorized");
409
409
 
410
410
  // On update, verify the current user owns this order
411
- if (entityId && previousValues) {
411
+ if (id && previousValues) {
412
412
  if (previousValues.user_id !== user.uid && !user.roles?.includes("admin")) {
413
413
  throw new Error("You can only edit your own orders");
414
414
  }
415
415
  }
416
416
 
417
417
  // On create, stamp ownership
418
- if (!entityId) {
418
+ if (!id) {
419
419
  values.user_id = user.uid;
420
420
  }
421
421
 
422
422
  return values;
423
423
  },
424
424
 
425
- beforeDelete: async ({ entity, context }) => {
425
+ beforeDelete: async ({ row, context }) => {
426
426
  const user = context.user;
427
427
  if (!user) throw new Error("Unauthorized");
428
428
 
429
- if (entity.values.user_id !== user.uid && !user.roles?.includes("admin")) {
429
+ if (row.user_id !== user.uid && !user.roles?.includes("admin")) {
430
430
  throw new Error("You can only delete your own orders");
431
431
  }
432
432
  },
@@ -437,15 +437,15 @@ const ordersCollection: PostgresCollection = {
437
437
 
438
438
  ### Combining Both Layers
439
439
 
440
- For maximum security, use both DataHooks and Entity Callbacks together:
440
+ For maximum security, use both DataHooks and Collection Callbacks together:
441
441
 
442
442
  | Concern | Best Layer | Why |
443
443
  |---|---|---|
444
444
  | Cross-cutting read filtering | DataHooks `afterRead` | Applies to ALL collections in one place |
445
445
  | Cross-cutting write validation | DataHooks `beforeSave` | Single enforcement point for all writes |
446
446
  | PII masking / field redaction | DataHooks `afterRead` | Cross-cutting, role-based |
447
- | Ownership checks on writes/deletes | Entity Callbacks | Has access to the full entity for comparison |
448
- | Business rule validation | Entity Callbacks | Collection-specific, typed values |
447
+ | Ownership checks on writes/deletes | Collection Callbacks | Has access to the full entity for comparison |
448
+ | Business rule validation | Collection Callbacks | Collection-specific, typed values |
449
449
  | Audit logging | DataHooks `afterSave` / `afterDelete` | Cross-cutting, fire-and-forget |
450
450
 
451
451
  ---
@@ -503,13 +503,44 @@ const hooks: BackendHooks = {
503
503
  },
504
504
 
505
505
  beforeDelete(slug, entityId, ctx) {
506
- // Tenant isolation on deletes is best handled in Entity Callbacks
506
+ // Tenant isolation on deletes is best handled in Collection Callbacks
507
507
  // where you have access to the entity's tenant_id
508
508
  },
509
509
  },
510
510
  };
511
511
  ```
512
512
 
513
+ ### Membership-Scoped Access (RLS, no N+1)
514
+
515
+ When membership lives in a **join collection** (e.g. `team_members`), prefer the
516
+ first-class `policy.existsIn` predicate over a per-row `afterRead` lookup. It is
517
+ enforced by Postgres RLS in a single correlated `EXISTS` subquery — no N+1, and
518
+ it cannot be bypassed by a client that skips the SDK.
519
+
520
+ ```typescript
521
+ import { policy } from "@rebasepro/types";
522
+
523
+ // config/collections/documents.ts — only members of the doc's team can read it:
524
+ securityRules: [
525
+ {
526
+ operation: "select",
527
+ condition: policy.existsIn({
528
+ collection: "team_members",
529
+ where: policy.and(
530
+ policy.compare(policy.field("team_id"), "eq", policy.outerField("team_id")),
531
+ policy.compare(policy.field("user_id"), "eq", policy.authUid()),
532
+ ),
533
+ }),
534
+ },
535
+ ]
536
+ ```
537
+
538
+ Inside `where`: `policy.field(...)` = a column of the joined collection
539
+ (`team_members`); `policy.outerField(...)` = a column of the row being checked
540
+ (`documents`); `policy.authUid()` = the caller. Reach for the `afterRead`
541
+ approach above only when access depends on data RLS can't see (e.g. an external
542
+ service). Run `rebase db push` after editing the collection to apply the policy.
543
+
513
544
  ### Role-Based Collection Access
514
545
 
515
546
  ```typescript
@@ -593,7 +624,7 @@ Use this checklist when setting up security for a Rebase project:
593
624
  - [ ] **Default role is NOT admin** — `auth.defaultRole` must never be `"admin"` (startup error)
594
625
  - [ ] **DataHooks enforce access control** — If not using database RLS, `hooks.data` enforces read/write/delete permissions
595
626
  - [ ] **Sensitive fields are masked** — `afterRead` masks PII for non-admin users
596
- - [ ] **Ownership is enforced** — `beforeSave` stamps `user_id` on creation; Entity Callbacks verify ownership on update/delete
627
+ - [ ] **Ownership is enforced** — `beforeSave` stamps `user_id` on creation; Collection Callbacks verify ownership on update/delete
597
628
  - [ ] **API keys are scoped** — API keys have minimal permissions (specific collections + operations)
598
629
  - [ ] **API keys are never client-side** — API keys bypass RLS; only use server-side
599
630
  - [ ] **CORS is configured** — Restrict origins in production
@@ -611,5 +642,5 @@ Use this checklist when setting up security for a Rebase project:
611
642
  - **Backend Hooks Types**: `packages/types/src/types/backend_hooks.ts` — `DataHooks`, `BackendHooks` interfaces
612
643
  - **Backend Init**: `packages/server-core/src/init.ts` — `hooks` config property
613
644
  - **Reserved Identity Values**: See Identity Types table above — `"service"`, `"anon"`, `"api-key:{id}"` are system-assigned identities in `context.user` / `ctx.requestUser`
614
- - **Entity Callbacks**: See `rebase-collections` skill → Entity Callbacks section
645
+ - **Collection Callbacks**: See `rebase-collections` skill → Collection Callbacks section
615
646
  - **Auth Configuration**: See `rebase-auth` skill → Server-Side Configuration section
@@ -672,12 +672,15 @@ const config = await client.storage.getSignedUrl(
672
672
  if (config.fileNotFound) {
673
673
  console.log("File does not exist");
674
674
  } else {
675
- console.log(config.url); // Full URL with auth token appended
675
+ console.log(config.url); // Private: short-lived scoped token appended. Public: clean permanent URL.
676
676
  console.log(config.metadata); // { bucket, fullPath, name, size, contentType, customMetadata }
677
677
  }
678
678
  ```
679
679
 
680
- The URL is constructed as `{baseUrl}/api/storage/file/{path}?token={accessToken}`.
680
+ **URL model never contains the access token:**
681
+
682
+ - **Private files** → `{baseUrl}/api/storage/file/{path}?token={scopedDownloadToken}`. The token is a **short-lived, path-scoped** download token (default 5 min), signed by the server and valid only for that object — never the caller's full access JWT. Because it expires, **do not persist a private URL**: store the file **path** and call `getSignedUrl()` again at render time.
683
+ - **Public files** → `{baseUrl}/api/storage/file/{path}` with **no token**. A file is public when it lives under the `public/` prefix — set `storage: { public: true }` on the property, or pass `public: true` to `putObject`. Public URLs are **stable, permanent, and CDN-cacheable**, so they are safe to store in a database and hotlink. For public paths, `getSignedUrl()` returns the URL immediately with no server round-trip.
681
684
 
682
685
  Protocol prefixes (`local://`, `s3://`) are automatically stripped from the key.
683
686
 
@@ -733,9 +736,9 @@ console.log(result.nextPageToken); // for next page, or undefined
733
736
  Define file upload fields in your collections using the `storage` option on string properties:
734
737
 
735
738
  ```typescript
736
- import { PostgresCollection } from "@rebasepro/types";
739
+ import { PostgresCollectionConfig } from "@rebasepro/types";
737
740
 
738
- const productsCollection: PostgresCollection = {
741
+ const productsCollection: PostgresCollectionConfig = {
739
742
  name: "Products",
740
743
  table: "products",
741
744
  properties: {
@@ -775,6 +778,7 @@ The `storageSource` field maps to a named backend registered in the `StorageRegi
775
778
  | `storagePath` | `string` | Path template for uploaded files. Supports `{entityId}` placeholder |
776
779
  | `acceptedFiles` | `string[]` | Accepted MIME types or globs (e.g., `["image/*"]`) |
777
780
  | `maxSize` | `number` | Maximum file size in bytes |
781
+ | `public` | `boolean` | Store files under the `public/` prefix and serve them via stable, token-less, permanent, CDN-cacheable URLs (safe to persist/hotlink). Defaults to `false` (private, short-lived signed URLs). |
778
782
 
779
783
  ## Frontend Storage Sources
780
784
 
@@ -256,8 +256,8 @@ These components can be overridden globally on `<Rebase>` (which acts as a fallb
256
256
  `<RebaseCMS>` accepts the full `RebaseCMSConfig`:
257
257
 
258
258
  ```typescript
259
- interface RebaseCMSConfig<EC extends EntityCollection = EntityCollection> {
260
- collections?: EC[] | EntityCollectionsBuilder<EC>;
259
+ interface RebaseCMSConfig<EC extends CollectionConfig = CollectionConfig> {
260
+ collections?: EC[] | CollectionConfigsBuilder<EC>;
261
261
  views?: AppView[] | AppViewsBuilder;
262
262
  homePage?: ReactNode;
263
263
  entityViews?: EntityCustomView[];
@@ -464,7 +464,7 @@ type CollectionPanelProps = {
464
464
  updateUrl?: boolean; // Sync filter/sort with URL (default: false)
465
465
  openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
466
466
  className?: string; // Container CSS class
467
- collectionOverrides?: Partial<EntityCollection>; // Additional overrides
467
+ collectionOverrides?: Partial<CollectionConfig>; // Additional overrides
468
468
  };
469
469
  ```
470
470
 
@@ -512,7 +512,7 @@ The Studio Bridge provides CMS capabilities to Studio components. When CMS is pr
512
512
  ```typescript
513
513
  interface StudioBridge {
514
514
  collectionRegistry: CollectionRegistryController;
515
- sideEntityController: SideEntityController;
515
+ sideEntityController: SidePanelController;
516
516
  urlController: UrlController;
517
517
  navigationState: NavigationStateController;
518
518
  breadcrumbs: BreadcrumbsController;
@@ -524,7 +524,7 @@ interface StudioBridge {
524
524
  | Hook | Return Type | Description |
525
525
  |------|-------------|-------------|
526
526
  | `useStudioCollectionRegistry()` | `CollectionRegistryController` | Access registered collections from Studio |
527
- | `useStudioSideEntityController()` | `SideEntityController` | Open/close entity side panels from Studio |
527
+ | `useStudioSidePanelController()` | `SidePanelController` | Open/close entity side panels from Studio |
528
528
  | `useStudioUrlController()` | `UrlController` | Build URLs and navigate from Studio |
529
529
  | `useStudioNavigationState()` | `NavigationStateController` | Access navigation state from Studio |
530
530
  | `useStudioBreadcrumbs()` | `BreadcrumbsController` | Set breadcrumbs from Studio tools |
@@ -534,7 +534,7 @@ All bridge hooks are exported from `@rebasepro/studio` (re-exported from `@rebas
534
534
  ```typescript
535
535
  import {
536
536
  useStudioCollectionRegistry,
537
- useStudioSideEntityController,
537
+ useStudioSidePanelController,
538
538
  useStudioUrlController,
539
539
  useStudioNavigationState,
540
540
  useStudioBreadcrumbs
@@ -567,7 +567,7 @@ import { StudioBridgeProvider } from "@rebasepro/studio";
567
567
 
568
568
  <StudioBridgeProvider value={{
569
569
  collectionRegistry: useCollectionRegistryController(),
570
- sideEntityController: useSideEntityController(),
570
+ sideEntityController: useSidePanel(),
571
571
  urlController: useUrlController(),
572
572
  navigationState: useNavigationStateController(),
573
573
  breadcrumbs: useBreadcrumbsController(),
@@ -610,7 +610,7 @@ These hooks are exported from `@rebasepro/core` and available inside any `<Rebas
610
610
 
611
611
  | Hook | Package | Description |
612
612
  |------|---------|-------------|
613
- | `useSideEntityController()` | `@rebasepro/admin` | Open/close entity side panels |
613
+ | `useSidePanel()` | `@rebasepro/admin` | Open/close entity side panels |
614
614
  | `useNavigationStateController()` | `@rebasepro/admin` | Navigate between views |
615
615
  | `useUrlController()` | `@rebasepro/admin` | Build URLs and navigate |
616
616
  | `useBreadcrumbsController()` | `@rebasepro/admin` | Set breadcrumbs |
@@ -5,7 +5,7 @@ description: Guide for sending outbound HTTP webhooks on entity changes in a Reb
5
5
 
6
6
  # Rebase Webhooks
7
7
 
8
- > **IMPORTANT FOR AGENTS**: The `WebhookDispatcher` is a **standalone service class** exported from `@rebasepro/server-core`. It is **not** auto-wired into the backend init pipeline — you must instantiate it yourself and call `onEntityChange()` from your application code (e.g. entity callbacks, custom functions, or cron jobs). Do NOT look for a `webhooks` key in `RebaseBackendConfig`.
8
+ > **IMPORTANT FOR AGENTS**: The `WebhookDispatcher` is a **standalone service class** exported from `@rebasepro/server-core`. It is **not** auto-wired into the backend init pipeline — you must instantiate it yourself and call `onEntityChange()` from your application code (e.g. collection callbacks, custom functions, or cron jobs). Do NOT look for a `webhooks` key in `RebaseBackendConfig`.
9
9
 
10
10
  > **IMPORTANT FOR AGENTS**: Webhooks are **outbound** HTTP POST requests sent by your Rebase backend to external URLs. They are NOT inbound endpoints. To receive webhooks FROM external services, use Rebase custom functions instead (see `rebase-custom-functions` skill).
11
11
 
@@ -71,7 +71,7 @@ dispatcher.setWebhooks([
71
71
 
72
72
  ### Dispatch on Entity Changes
73
73
 
74
- Call `onEntityChange()` whenever an entity is created, updated, or deleted. The dispatcher checks all registered webhooks and fires matching ones.
74
+ Call `onEntityChange()` whenever a entity is created, updated, or deleted. The dispatcher checks all registered webhooks and fires matching ones.
75
75
 
76
76
  ```typescript
77
77
  const results = await dispatcher.onEntityChange(
@@ -355,13 +355,13 @@ If the receiver does not respond within 10 seconds, the request is aborted and t
355
355
 
356
356
  ## Integration Patterns
357
357
 
358
- ### With Entity Callbacks
358
+ ### With Collection Callbacks
359
359
 
360
- The most common pattern is to wire the dispatcher into Rebase entity callbacks so webhooks fire automatically on CRUD operations:
360
+ The most common pattern is to wire the dispatcher into Rebase collection callbacks so webhooks fire automatically on CRUD operations:
361
361
 
362
362
  ```typescript
363
363
  // backend/collections/orders.ts
364
- import type { EntityCollection, EntityCallbacks } from "@rebasepro/types";
364
+ import type { CollectionConfig, CollectionCallbacks } from "@rebasepro/types";
365
365
  import { WebhookDispatcher } from "@rebasepro/server-core/services/webhook-service";
366
366
 
367
367
  const dispatcher = new WebhookDispatcher();
@@ -376,35 +376,28 @@ dispatcher.setWebhooks([
376
376
  },
377
377
  ]);
378
378
 
379
- const callbacks: EntityCallbacks = {
380
- afterCreate: async ({ entity, collection }) => {
379
+ const callbacks: CollectionCallbacks = {
380
+ afterSave: async ({ id, values, previousValues, status, collection }) => {
381
+ const event = status === "new" ? "INSERT" : "UPDATE";
381
382
  await dispatcher.onEntityChange(
382
383
  collection.path,
383
- "INSERT",
384
- entity.id,
385
- entity
386
- );
387
- },
388
- afterUpdate: async ({ entity, previousEntity, collection }) => {
389
- await dispatcher.onEntityChange(
390
- collection.path,
391
- "UPDATE",
392
- entity.id,
393
- entity,
394
- previousEntity
384
+ event,
385
+ String(id),
386
+ values,
387
+ status === "new" ? undefined : previousValues
395
388
  );
396
389
  },
397
- afterDelete: async ({ entityId, collection }) => {
390
+ afterDelete: async ({ id, collection }) => {
398
391
  await dispatcher.onEntityChange(
399
392
  collection.path,
400
393
  "DELETE",
401
- entityId,
394
+ String(id),
402
395
  null
403
396
  );
404
397
  },
405
398
  };
406
399
 
407
- const ordersCollection: EntityCollection = {
400
+ const ordersCollection: CollectionConfig = {
408
401
  name: "Orders",
409
402
  path: "orders",
410
403
  callbacks,
@@ -519,9 +512,11 @@ Then import it from any callback or function:
519
512
  ```typescript
520
513
  import { dispatcher } from "../lib/webhooks";
521
514
 
522
- // In an entity callback:
523
- afterCreate: async ({ entity, collection }) => {
524
- await dispatcher.onEntityChange(collection.path, "INSERT", entity.id, entity);
515
+ // In a collection callback:
516
+ afterSave: async ({ id, values, status, collection }) => {
517
+ if (status === "new") {
518
+ await dispatcher.onEntityChange(collection.path, "INSERT", String(id), values);
519
+ }
525
520
  },
526
521
  ```
527
522
 
@@ -535,7 +530,7 @@ Registers the list of webhooks to watch. Filters out any with `enabled: false`.
535
530
  |-----------|------|-------------|
536
531
  | `webhooks` | `WebhookConfig[]` | Array of webhook configurations. |
537
532
 
538
- ### `onEntityChange(table, event, entityId, entity, previousEntity?): Promise<WebhookDeliveryResult[]>`
533
+ ### `onEntityChange(table, event, id, entity, previousEntity?): Promise<WebhookDeliveryResult[]>`
539
534
 
540
535
  Checks all registered webhooks for matching `table` + `event`, and dispatches to each match.
541
536
 
@@ -543,7 +538,7 @@ Checks all registered webhooks for matching `table` + `event`, and dispatches to
543
538
  |-----------|------|-------------|
544
539
  | `table` | `string` | The database table name (e.g. `"orders"`). |
545
540
  | `event` | `"INSERT" \| "UPDATE" \| "DELETE"` | The type of entity change. |
546
- | `entityId` | `string` | The unique ID of the changed entity. |
541
+ | `id` | `string` | The unique ID of the changed entity. |
547
542
  | `entity` | `Record<string, unknown> \| null` | The current entity data. May be `null` for deletes. |
548
543
  | `previousEntity` | `Record<string, unknown> \| null` | *(Optional)* The previous entity state. Only relevant for `UPDATE` events — included as `old_record` in the payload. |
549
544
 
@@ -557,7 +552,7 @@ The dispatcher supports three event types, passed as the `event` parameter to `o
557
552
  |-------|-------------|--------------------|-----------------------|
558
553
  | `INSERT` | A new entity was created | The new entity | `undefined` |
559
554
  | `UPDATE` | An existing entity was modified | The updated entity | The entity before update |
560
- | `DELETE` | An entity was removed | The deleted entity (or `null`) | `undefined` |
555
+ | `DELETE` | a entity was removed | The deleted entity (or `null`) | `undefined` |
561
556
 
562
557
  ## Error Handling
563
558
 
@@ -592,9 +587,9 @@ for (const result of results) {
592
587
  If you don't want webhook delivery to block your API response, use fire-and-forget:
593
588
 
594
589
  ```typescript
595
- afterCreate: async ({ entity, collection }) => {
590
+ afterSave: async ({ id, values, collection }) => {
596
591
  // Fire-and-forget — don't await
597
- dispatcher.onEntityChange(collection.path, "INSERT", entity.id, entity)
592
+ dispatcher.onEntityChange(collection.path, "INSERT", String(id), values)
598
593
  .catch(err => console.error("Webhook dispatch error:", err));
599
594
  },
600
595
  ```