feat(client-react): bulk-write hooks, and useAutoRefresh refreshes on predicate writes (#4678) - #4683
Merged
Merged
Conversation
…t for predicate writes (#4639) A `multi: true` update/delete reaches `IDataDriver.updateMany`/`deleteMany`, 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 `recordId: ''` with `after: <count>` — an event every schema-compliant consumer must reject, which 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 webhooks, knowledge sync and `subscribeData` all went silent for predicate writes. Bulk writes now get their own contract rather than impersonating a per-record one or going dark. - spec: new `BulkDataEventType` / `BulkDataEventSchema` — `data.records.updated` / `data.records.deleted` carrying `object` and `matched`. A separate schema, not a widened `DataEvent`: the type alone tells a consumer no `recordId` is coming, instead of it discovering an empty string at runtime. No `where` — the only predicate in hand at publish time is the middleware-composed AST, whose filter embeds the security layer's injected row scoping (RLS, sharing), and publishing it would ship tenant internals to whatever external URL a webhook points at. - objectql: `publishBulkDataEvent` from the two `multi` branches, validated before publish. A predicate matching zero rows publishes nothing (no data changed), and a driver resolving a non-count publishes nothing and warns rather than asserting an unverified number. Per-record writes are untouched, including a scalar `where.id` with `multi: true`, which stays a single-record target. - plugin-webhooks: opt-in `bulk_update` / `bulk_delete` triggers. Not extra sources for `create`/`update`/`delete` — the body has no `recordId` and no record, so routing it to per-record subscribers would hand them a payload missing every field they read. Dedups on the producer's event uuid, since two sweeps in the same millisecond are distinct events a timestamp key would collapse. Self-heal now also refreshes on a predicate write to `sys_webhook`. - client: `subscribeBulkData`, with the same loud boundary validation. Separate from `subscribeData` so a `BulkDataEvent` never reaches a `(event: DataEvent) => void` callback. - service-knowledge: a knowledge index is a per-record projection and a count names no record, so bulk events cannot drive it. Says so rather than no-opping silently; reconciliation tracked in #4672. Also pays off the measurement debt from #4655, which claimed the write-path cost of event publishing had been measured but never published it: `engine-data-events.bench.ts` puts it at ~7-9us per event against an in-memory driver, paid once per bulk write regardless of match-set size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT
…lk-write gap The sync-model section named the legacy unprefixed `record.*` events; the engine has published `data.record.*` since #4626, and the plugin reads the record body from `after` and a delete's id from the required `recordId`. Also states what a predicate write does to an object source: it publishes the aggregate `data.records.*` (#4639), which names no record, so the index goes stale in a way the event stream cannot repair. Reconciliation is #4672. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT
…ollect samples The benchmark was added after the local typecheck run, so two real errors rode into CI: - `registry.registerObject` requires a `packageId`. The sibling `.test.ts` calls it with one argument and gets away with it only because this package's tsconfig excludes `**/*.test.ts` (measured test debt); a `.bench.ts` is not excluded, so it is checked — correctly. - The package compiles to CommonJS, where the module-scope `await` used to build the engine pairs is TS1309. Hoisting setup into `beforeAll` fixes the types but breaks the benchmark: vitest's benchmark mode is experimental and does not run the hook, so every engine stayed `undefined`, every iteration threw, and the summary reported `NaNx faster` off zero samples. Uses a memoized lazy init inside the benches instead — construction lands in vitest's warmup, outside the measured samples, and the settled-promise await is paid identically by both arms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT
… predicate writes (#4678) #4639 gave predicate writes (`multi: true`) their own event contract and `@objectstack/client` exposes it as `subscribeBulkData`, but all three React realtime data hooks still delegated to `subscribeData` — so React consumers could not see bulk writes at all. The sharpest edge was `useAutoRefresh`: its whole job is "refetch when the data changes", and a predicate write is what dirties a list hardest — one statement can change or delete every row on screen. It sat still for those while refetching dutifully for a single-row edit. - Adds `useBulkDataSubscription` and `useBulkDataSubscriptionCallback`. - `useAutoRefresh` watches both streams. Safe here in a way it is not for `useDataSubscription`, because this hook's output is a refetch signal rather than an event body, so the shape difference between the two contracts never reaches the caller. With `options.recordId` set it still refetches on a bulk event: a count cannot say whether that record was in the match set, and a redundant query beats showing a row a predicate write already changed. - `useDataSubscription` / `useDataSubscriptionCallback` stay per-record only — their callbacks are typed `(event: DataEvent) => void`. Stacked on the #4639 branch because it consumes `subscribeBulkData`, which ships there; merge that first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
Adding `bulk_update` / `bulk_delete` to the `sys_webhook.triggers` select adds two option labels to the generated translation bundles, which `check:i18n` caught as drift in all four locales. Regenerated with `node scripts/check-i18n-bundles.mjs --write` (merge mode, so no existing translation was overwritten), then translated the two new labels — merge mode fills new keys with the raw source text, which would otherwise ship `bulk_update` verbatim as a zh/ja/es UI label and count against `check:i18n-coverage`. English keeps the machine names, matching how create/update/delete already read there. Also corrects the `triggers` help text in all four locales: it still described a "comma-separated event list", which the field stopped being when it became a multi-select, and it named only the three per-record events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT
Base automatically changed from
claude/bulk-write-missing-events-sm1i4b
to
main
August 2, 2026 17:15
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4678
问题
#4639 给谓词写(
multi: true)立了独立事件契约,@objectstack/client也开了subscribeBulkData。但 React hooks 没跟上——packages/client-react/src/realtime-hooks.tsx里三个 data hook 全部委托subscribeData,于是 React 用户完全看不到批量写。最刺眼的是
useAutoRefresh:它的全部职责就是"数据变了就重取",而谓词写恰恰是把列表弄脏得最厉害的那种写入——一条语句就能改掉或删掉屏幕上每一行。它对这种情况纹丝不动,却对单条记录的改动老老实实刷新。这不是性能取舍,是语义漏洞。
改动
新增两个 hook:
useBulkDataSubscription(object)返回最新的BulkDataEvent;useBulkDataSubscriptionCallback(object, cb)供重取/副作用场景。useAutoRefresh同时订两条流。 这里混流是安全的,而在useDataSubscription里不安全——区别在于本 hook 的产物是重取信号,不是事件体,所以那个把两个契约分开的形状差异(没有recordId、没有记录体)根本到不了调用方。一个明确的取舍:当
options.recordId把它收窄到单条记录时,收到批量事件仍然重取。批量事件只有计数,说不出那条记录是否在匹配集里;多发一次查询很便宜,而另一种选择是把一条已经被谓词写改过的记录继续显示给用户。useDataSubscription/useDataSubscriptionCallback不动,保持只收逐记录。它们的回调签名是(event: DataEvent) => void,放批量事件进去就是把 #4626 消灭的那个缺陷原样请回来——类型上有、运行时undefined。验证
pnpm --filter @objectstack/client-react typecheck✅pnpm --filter @objectstack/client-react build✅(tsup CJS + DTS)npx eslint packages/client-react/src✅没有加测试,原因写在 #4682
client-react没有任何测试基座:package.json只有build和typecheck,没有test;全仓也没有根 vitest 配置,grep 不到jsdom,grep 不到@testing-library/*。给 hook 写测试因此不是"加一个
.test.tsx",而是要引入两个新的 devDependency、配 jsdom environment、并让它进 CI 的Test Core分片——这是该由维护者定的取舍(新依赖 + CI 时长),不该作为一个 hook 修复的 rider 夹带进来。已按第十条军规立案 #4682,并在那里点明了讽刺之处:本 PR 修的正是一个 typecheck 永远看不见的行为缺陷,同类缺陷可以再次静默溜进来。那个 issue 列了三件起步就该覆盖的事(依赖数组导致的重订阅、卸载退订、回调确实被调用)。
🤖 Generated with Claude Code
https://claude.ai/code/session_01LYnZrTwXbrctB8E8HpJAPT
Generated by Claude Code