Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .changeset/bulk-data-event-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/client": minor
"@objectstack/plugin-webhooks": minor
"@objectstack/service-knowledge": patch
---

feat(spec,objectql,client,plugin-webhooks): predicate writes get an honest bulk event contract (#4639)

A `multi: true` update/delete reaches `IDataDriver.updateMany` / `deleteMany`,
which are contracted to resolve an affected row COUNT and nothing else. That
satisfies neither `DataEvent.recordId` (required) nor `before` / `after` /
`changes`, so before #4626 the engine fabricated a per-record event with
`recordId: ''` and `after: <count>` — an event every schema-compliant consumer
must reject, and one the webhook enqueuer's `?? 'unknown'` fallback turned into
a real delivery naming an unidentifiable record. #4626 removed the fabrication
and published nothing instead: honest, but it left webhooks, knowledge sync and
`subscribeData` silent for every predicate write.

Bulk writes now get their **own** contract rather than impersonating a
per-record one or going dark:

- **New `BulkDataEvent`** (`@objectstack/spec/api`): `data.records.updated` /
`data.records.deleted` — note the plural — carrying `id`, `type`, `object`,
`matched`, `userId?`, `timestamp`. Deliberately a separate schema from
`DataEvent`, not a widened one: a consumer that receives
`data.records.updated` knows from the type alone that no `recordId` is
coming, instead of discovering an empty string at runtime.
- **Engine** publishes it from the `multi: true` branches of `update()` /
`delete()`, validated with `BulkDataEventSchema.parse` before publish. A
predicate that matched **zero** rows publishes nothing (no data changed — this
is what keeps an idle background sweep from becoming an hourly "0 records"
delivery), and a driver that resolves a non-count publishes nothing and warns
rather than asserting a number it cannot verify. Per-record writes are
untouched, including a scalar `where.id` with `multi: true`, which is still a
single-record target and still emits `data.record.deleted`.
- **Webhooks**: two new opt-in triggers, `bulk_update` and `bulk_delete`
(`WebhookTriggerType`, and the `sys_webhook.triggers` multi-select). They are
**not** extra sources for `create` / `update` / `delete`: the delivered body
has no `recordId` and no record, so routing it to existing per-record
subscribers would hand them a payload missing every field they read — the
same class of breakage as the old `recordId: ''`, from the other direction. A
webhook that wants both subscribes to both. Bulk deliveries dedup on the
producer's event uuid, since two sweeps in the same millisecond are genuinely
different events that a timestamp-based key would collapse.
- **Client SDK**: new `client.events.subscribeBulkData(object, cb)`, with the
same loud boundary validation as `subscribeData`. Kept a separate method for
the same reason — delivering a `BulkDataEvent` to a `(event: DataEvent) =>
void` callback would recreate exactly the "typed field, `undefined` at
runtime" defect #4626 removed. `subscribeData`'s own guard was also tightened
from `data.` to `data.record.`, so an aggregate event is ignored rather than
rejected as off-contract.
- **Knowledge sync** now says out loud that a predicate write leaves its index
stale. A knowledge index is a per-record projection and `matched: 40` names no
record, so no event shape could drive it — the durable fix is reconciliation,
tracked in #4672.

The event carries no `where` predicate. The only one available at publish time
is the middleware-composed AST, whose filter embeds the security layer's
injected row scoping (RLS, sharing) — publishing it would ship tenant scoping
internals to whatever external URL a webhook points at.

Also pays off a measurement debt from #4655, which claimed the write-path cost
of event publishing had been measured but never published the numbers:
`packages/objectql/src/engine-data-events.bench.ts` measures it. Against an
in-memory driver, publishing costs ~7–9µs per event (insert 0.021ms vs 0.012ms,
single-id update 0.013ms vs 0.007ms). A bulk write pays that **once** regardless
of how many rows matched (0.040ms vs 0.034ms over a 100-row match set), so its
relative cost shrinks as the match set grows.
60 changes: 54 additions & 6 deletions content/docs/automation/webhooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ in `definition_json`, a serialised `Webhook` JSON (canonical schema:
| `name` | text | Unique snake_case name — referenced in logs and audit. |
| `label` | text | Optional display label. |
| `object_name` | text | Short object name whose record events fire this webhook. |
| `triggers` | select | Multi-select of `create` / `update` / `delete`, stored as an array (the enqueuer also accepts a legacy comma-separated string). |
| `triggers` | select | Multi-select of `create` / `update` / `delete` plus the opt-in bulk pair `bulk_update` / `bulk_delete` ([see below](#bulk-writes-bulk_update-and-bulk_delete)), stored as an array (the enqueuer also accepts a legacy comma-separated string). |
| `url` | text | External endpoint that receives the POST. |
| `method` | select | HTTP method — one of `GET` / `POST` / `PUT` / `PATCH` / `DELETE`. Default `POST`. |
| `description` | textarea | Free-text description. |
Expand Down Expand Up @@ -214,11 +214,59 @@ is the spec's `DataEvent` (`@objectstack/spec/api`) — validated against
}
```

> **A multi-row write emits no record event.** `updateMany` / `deleteMany`
> (`multi: true`) return only an affected count, so there is no record for a
> `DataEvent` to name and the engine publishes nothing rather than an event
> with an empty `recordId` — meaning webhooks do **not** fire for bulk writes
> today. Tracked in [#4639](https://github.com/objectstack-ai/objectstack/issues/4639).
### Bulk writes: `bulk_update` and `bulk_delete`

A predicate write — `updateMany` / `deleteMany` (`multi: true`) — reports only
an affected count, so there is no record for a `DataEvent` to name. Rather than
publish an event with an empty `recordId` (which every schema-compliant
consumer must reject), the engine publishes a **separate** aggregate event
(#4639):

```ts
{
type: 'data.records.updated', // note: recordS — plural
object: 'account',
timestamp: '<ISO 8601>',
payload: {
id: '<uuid>', // unique event id
type: 'data.records.updated',
object: 'account',
matched: 40, // how many records the predicate affected
userId: 'usr_1', // when the write names an actor
timestamp: '<ISO 8601>',
},
}
```

These dispatch under their own triggers, `bulk_update` and `bulk_delete`, and
they are **opt-in**: a webhook declaring `update` does not receive them. That
is deliberate — the body has no `recordId` and no record, so delivering it to a
subscriber written against the per-record shape would hand it a payload missing
everything it reads.

```ts
webhooks: [{
name: 'account_bulk_audit',
object: 'account',
triggers: ['update', 'bulk_update'], // subscribe to both if you want both
url: 'https://example.com/hooks/accounts',
}]
```

Two properties worth knowing:

- **No event when nothing matched.** A predicate that affected zero rows
changed no data, so it publishes nothing — an idle background sweep does not
become an hourly "0 records" delivery.
- **The predicate is not included.** The only filter available at publish time
is the query after the security layer composed row scoping into it (RLS,
sharing), so sending it would leak tenant scoping internals to the
destination URL. The event states the count and nothing more.

Because a count names no rows, a bulk delivery cannot drive an incremental
per-record projection (a cache, a search index, a mirror). Use it to invalidate,
alert, or schedule a refetch; anything that must know *which* records changed
has to reconcile against the source.

> **Not yet cluster-aware.** The only shipped `IRealtimeService` implementation
> is `InMemoryRealtimeAdapter`, an in-process, single-node pub/sub with no
Expand Down
21 changes: 18 additions & 3 deletions content/docs/protocol/knowledge.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -296,16 +296,31 @@ with a `permissions` mapping function at index time.

## 6. Sync model

For `object` sources, `KnowledgeService` subscribes to ObjectQL
`record.created`, `record.updated`, and `record.deleted` events. Each
event triggers a single `adapter.upsert` / `adapter.delete` call.
For `object` sources, `KnowledgeService` subscribes to the ObjectQL engine's
`data.record.created`, `data.record.updated` and `data.record.deleted` events
(the legacy unprefixed `record.*` shape is still accepted). Each event triggers
a single `adapter.upsert` / `adapter.delete` call — the record body is read
from the event's `after`, and a delete's id from its required `recordId`.

- **MVP (Phase 1):** synchronous, inline with the originating mutation.
Fast for low-volume dev / demo. Indexing failures are logged but do
not block the originating write.
- **Phase 2:** async via `service-queue` for batching, retries, and
back-pressure.

<Callout type="warn">
**A predicate write leaves the index stale.** A `multi: true` update/delete
reaches `updateMany` / `deleteMany`, which report only an affected row count,
so it publishes the aggregate `data.records.updated` / `data.records.deleted`
([#4639](https://github.com/objectstack-ai/objectstack/issues/4639)) rather
than per-record events. A knowledge index is a per-record projection and
`matched: 40` names no record, so there is no upsert or delete to derive —
the service logs a warning naming the object and count instead of failing
silently. Reconciliation against the source object is the durable fix and is
tracked in [#4672](https://github.com/objectstack-ai/objectstack/issues/4672):
events keep the index *fresh*, reconciliation keeps it *correct*.
</Callout>

`file` / `http` sources rely on explicit `reindexSource` calls
(typically triggered by a cron job, a Console button, or a webhook).

Expand Down
32 changes: 29 additions & 3 deletions content/docs/references/api/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,39 @@ Examples:
## TypeScript Usage

```typescript
import { DataEventSchema, DataEventType, MetadataEventSchema, MetadataEventType } from '@objectstack/spec/api';
import type { DataEvent, DataEventType, MetadataEvent, MetadataEventType } from '@objectstack/spec/api';
import { BulkDataEventSchema, BulkDataEventType, DataEventSchema, DataEventType, MetadataEventSchema, MetadataEventType } from '@objectstack/spec/api';
import type { BulkDataEvent, BulkDataEventType, DataEvent, DataEventType, MetadataEvent, MetadataEventType } from '@objectstack/spec/api';

// Validate data
const result = DataEventSchema.parse(data);
const result = BulkDataEventSchema.parse(data);
```

---

## BulkDataEvent

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **id** | `string` | ✅ | Unique event identifier |
| **type** | `Enum<'data.records.updated' \| 'data.records.deleted'>` | ✅ | Event type |
| **object** | `string` | ✅ | Object name |
| **matched** | `integer` | ✅ | Number of records affected |
| **userId** | `string` | optional | User who triggered the event |
| **timestamp** | `string` | ✅ | Event timestamp |


---

## BulkDataEventType

### Allowed Values

* `data.records.updated`
* `data.records.deleted`


---

## DataEvent
Expand Down
28 changes: 25 additions & 3 deletions content/docs/references/automation/webhook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,26 @@ producer are declared here — an author can't subscribe to something that

never fires.

**Bulk triggers (#4639).** `bulk_update` / `bulk_delete` map to the engine's

aggregate `data.records.updated` / `data.records.deleted`, emitted when a

predicate write (`multi: true` → `IDataDriver.updateMany`/`deleteMany`)

affects a set of rows the driver reports only as a count. They are separate

trigger values, not extra sources for `update` / `delete`, because their

delivery has a different SHAPE: no `recordId`, no record body, just

`object` + `matched`. Folding them into the per-record triggers would send

every existing subscriber a body missing the fields it reads — the same

class of breakage as the pre-#4626 `recordId: ''` fabrication, arriving from

the other direction. A webhook that wants both subscribes to both.

Deliberately NOT triggers (#3196):

- `undelete` — there is no soft-delete / restore capability in the engine
Expand Down Expand Up @@ -47,7 +67,7 @@ value that silently never fires.

```typescript
import { WebhookSchema, WebhookTriggerType } from '@objectstack/spec/automation';
import type { Webhook } from '@objectstack/spec/automation';
import type { Webhook, WebhookTriggerType } from '@objectstack/spec/automation';

// Validate data
const result = WebhookSchema.parse(data);
Expand All @@ -63,8 +83,8 @@ const result = WebhookSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Webhook unique name (lowercase snake_case) |
| **label** | `string` | optional | Human-readable webhook label |
| **object** | `string` | optional | Object whose record events (create/update/delete) trigger this webhook |
| **triggers** | `Enum<'create' \| 'update' \| 'delete'>[]` | optional | Events that trigger execution |
| **object** | `string` | optional | Object whose record events (create/update/delete, bulk_update/bulk_delete) trigger this webhook |
| **triggers** | `Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]` | optional | Events that trigger execution |
| **url** | `string` | ✅ | External webhook endpoint URL |
| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>` | ✅ | HTTP method |
| **headers** | `Record<string, string>` | optional | Custom HTTP headers |
Expand All @@ -83,6 +103,8 @@ const result = WebhookSchema.parse(data);
* `create`
* `update`
* `delete`
* `bulk_update`
* `bulk_delete`


---
Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/integration/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Circuit breaker configuration
| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) |
| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration |
| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record<string, any> }; defaultValue?: any; … }[]` | optional | Field mapping rules |
| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) |
| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) |
| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms |
Expand Down Expand Up @@ -336,7 +336,7 @@ Connector type
| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions (not yet enforced — never read at registration; see #3197) |
| **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration |
| **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record<string, any> }; defaultValue?: any; … }[]` | optional | Field mapping rules |
| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) |
| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations (not yet enforced — never read at registration; see #3197) |
| **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `number` | optional | Connection timeout in ms |
Expand Down Expand Up @@ -459,8 +459,8 @@ Synchronization strategy
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Webhook unique name (lowercase snake_case) |
| **label** | `string` | optional | Human-readable webhook label |
| **object** | `string` | optional | Object whose record events (create/update/delete) trigger this webhook |
| **triggers** | `Enum<'create' \| 'update' \| 'delete'>[]` | optional | Events that trigger execution |
| **object** | `string` | optional | Object whose record events (create/update/delete, bulk_update/bulk_delete) trigger this webhook |
| **triggers** | `Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]` | optional | Events that trigger execution |
| **url** | `string` | ✅ | External webhook endpoint URL |
| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>` | ✅ | HTTP method |
| **headers** | `Record<string, string>` | optional | Custom HTTP headers |
Expand Down
Loading
Loading