Skip to content

feat(client-react): bulk-write hooks, and useAutoRefresh refreshes on predicate writes (#4678) - #4683

Merged
os-zhuang merged 7 commits into
mainfrom
claude/issue-4678-client-react-bulk-hooks
Aug 2, 2026
Merged

feat(client-react): bulk-write hooks, and useAutoRefresh refreshes on predicate writes (#4678)#4683
os-zhuang merged 7 commits into
mainfrom
claude/issue-4678-client-react-bulk-hooks

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #4678

⚠️ Stacked on #4677(base 指向 claude/bulk-write-missing-events-sm1i4b,不是 main)。它消费 #4677 才引入的 subscribeBulkData#4677 合并后 GitHub 会自动把 base 改回 main,届时本 PR 的 diff 仍只有下面这三个文件。

问题

#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 只有 buildtypecheck,没有 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

claude added 4 commits August 2, 2026 16:23
…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
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 2, 2026 5:21pm

Request Review

@github-actions github-actions Bot added the size/m label Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client-react.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/client-react)
  • content/docs/api/client-sdk.mdx (via @objectstack/client-react)
  • content/docs/plugins/packages.mdx (via @objectstack/client-react)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling labels Aug 2, 2026
claude added 2 commits August 2, 2026 16:54
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
@os-zhuang
os-zhuang marked this pull request as ready for review August 2, 2026 17:33
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 2, 2026
Merged via the queue into main with commit 0884452 Aug 2, 2026
21 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-4678-client-react-bulk-hooks branch August 2, 2026 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

client-react 拿不到批量写事件:useAutoRefresh 对 multi:true 不刷新,三个 realtime hook 都没有 subscribeBulkData 的对应物

2 participants