|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +"@objectstack/objectql": minor |
| 4 | +"@objectstack/client": minor |
| 5 | +"@objectstack/plugin-webhooks": minor |
| 6 | +"@objectstack/service-knowledge": patch |
| 7 | +--- |
| 8 | + |
| 9 | +feat(spec,objectql,client,plugin-webhooks): predicate writes get an honest bulk event contract (#4639) |
| 10 | + |
| 11 | +A `multi: true` update/delete reaches `IDataDriver.updateMany` / `deleteMany`, |
| 12 | +which are contracted to resolve an affected row COUNT and nothing else. That |
| 13 | +satisfies neither `DataEvent.recordId` (required) nor `before` / `after` / |
| 14 | +`changes`, so before #4626 the engine fabricated a per-record event with |
| 15 | +`recordId: ''` and `after: <count>` — an event every schema-compliant consumer |
| 16 | +must reject, and one the webhook enqueuer's `?? 'unknown'` fallback turned into |
| 17 | +a real delivery naming an unidentifiable record. #4626 removed the fabrication |
| 18 | +and published nothing instead: honest, but it left webhooks, knowledge sync and |
| 19 | +`subscribeData` silent for every predicate write. |
| 20 | + |
| 21 | +Bulk writes now get their **own** contract rather than impersonating a |
| 22 | +per-record one or going dark: |
| 23 | + |
| 24 | +- **New `BulkDataEvent`** (`@objectstack/spec/api`): `data.records.updated` / |
| 25 | + `data.records.deleted` — note the plural — carrying `id`, `type`, `object`, |
| 26 | + `matched`, `userId?`, `timestamp`. Deliberately a separate schema from |
| 27 | + `DataEvent`, not a widened one: a consumer that receives |
| 28 | + `data.records.updated` knows from the type alone that no `recordId` is |
| 29 | + coming, instead of discovering an empty string at runtime. |
| 30 | +- **Engine** publishes it from the `multi: true` branches of `update()` / |
| 31 | + `delete()`, validated with `BulkDataEventSchema.parse` before publish. A |
| 32 | + predicate that matched **zero** rows publishes nothing (no data changed — this |
| 33 | + is what keeps an idle background sweep from becoming an hourly "0 records" |
| 34 | + delivery), and a driver that resolves a non-count publishes nothing and warns |
| 35 | + rather than asserting a number it cannot verify. Per-record writes are |
| 36 | + untouched, including a scalar `where.id` with `multi: true`, which is still a |
| 37 | + single-record target and still emits `data.record.deleted`. |
| 38 | +- **Webhooks**: two new opt-in triggers, `bulk_update` and `bulk_delete` |
| 39 | + (`WebhookTriggerType`, and the `sys_webhook.triggers` multi-select). They are |
| 40 | + **not** extra sources for `create` / `update` / `delete`: the delivered body |
| 41 | + has no `recordId` and no record, so routing it to existing per-record |
| 42 | + subscribers would hand them a payload missing every field they read — the |
| 43 | + same class of breakage as the old `recordId: ''`, from the other direction. A |
| 44 | + webhook that wants both subscribes to both. Bulk deliveries dedup on the |
| 45 | + producer's event uuid, since two sweeps in the same millisecond are genuinely |
| 46 | + different events that a timestamp-based key would collapse. |
| 47 | +- **Client SDK**: new `client.events.subscribeBulkData(object, cb)`, with the |
| 48 | + same loud boundary validation as `subscribeData`. Kept a separate method for |
| 49 | + the same reason — delivering a `BulkDataEvent` to a `(event: DataEvent) => |
| 50 | + void` callback would recreate exactly the "typed field, `undefined` at |
| 51 | + runtime" defect #4626 removed. `subscribeData`'s own guard was also tightened |
| 52 | + from `data.` to `data.record.`, so an aggregate event is ignored rather than |
| 53 | + rejected as off-contract. |
| 54 | +- **Knowledge sync** now says out loud that a predicate write leaves its index |
| 55 | + stale. A knowledge index is a per-record projection and `matched: 40` names no |
| 56 | + record, so no event shape could drive it — the durable fix is reconciliation, |
| 57 | + tracked in #4672. |
| 58 | + |
| 59 | +The event carries no `where` predicate. The only one available at publish time |
| 60 | +is the middleware-composed AST, whose filter embeds the security layer's |
| 61 | +injected row scoping (RLS, sharing) — publishing it would ship tenant scoping |
| 62 | +internals to whatever external URL a webhook points at. |
| 63 | + |
| 64 | +Also pays off a measurement debt from #4655, which claimed the write-path cost |
| 65 | +of event publishing had been measured but never published the numbers: |
| 66 | +`packages/objectql/src/engine-data-events.bench.ts` measures it. Against an |
| 67 | +in-memory driver, publishing costs ~7–9µs per event (insert 0.021ms vs 0.012ms, |
| 68 | +single-id update 0.013ms vs 0.007ms). A bulk write pays that **once** regardless |
| 69 | +of how many rows matched (0.040ms vs 0.034ms over a 100-row match set), so its |
| 70 | +relative cost shrinks as the match set grows. |
0 commit comments