Skip to content

Commit 0c0ede0

Browse files
fix(client): subscribeData delivers data.record.restored, reading its event types from the spec (#31509)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X2kM3DQTy5XByDHKN47ZrVA)_ domain:server#5 · os-project-manager · session_01FEkQ5mbAyTfp7gbjowRA48 Fixes #31473 Clause-②: yes (widening) — `RealtimeAPI.subscribeData` now delivers every per-record event type the spec's `DataEventType` declares, which adds `data.record.restored` (a row restored from the recycle bin) to the created / updated / deleted it delivered before. `subscribeBulkData` reads `BulkDataEventType` the same way; its two types are unchanged. Metadata-protocol: none ## 维护者速读 - **改了什么**: `@objectstack/client` 的 `subscribeData` / `subscribeBulkData` 订阅的事件类型改为直接读 spec 的 `DataEventType` / `BulkDataEventType` 枚举,不再手写列表;回收站恢复产生的 `data.record.restored` 因此能送达订阅者。 - **为什么改**: #31220 给 `DataEventType` 加了 `restored`,但 SDK 手写的 created/updated/deleted 列表把它过滤掉,订阅者的实时列表看不到被恢复的记录 (#31473)。 - **风险与回滚**: 只多送一种事件;按 `event.type` 分支的回调会多看到 `data.record.restored`(载荷 `after` 带恢复后的行)。客户端本就运行时依赖 `@objectstack/spec/api`,无新依赖。回滚即 revert 本 PR。 - **先看**: `packages/client/src/realtime-api.ts` 中 `subscribeData` 与 `subscribeBulkData` 的 `eventTypes`。 - **两分钟验证**: `cd packages/client && npx vitest run src/realtime-api-data-event-types.test.ts`,预期 4 个用例全过 (在 main 上 3 个红)。 - **需要你做的**: 无,席位落地。 ## What changes - `subscribeData` asked for `['data.record.created', 'data.record.updated', 'data.record.deleted']`, written by hand. `data.record.restored`, which ObjectQL's recycle-bin restore publishes with the restored row in `after` and which #31220 added to `DataEventType`, matched no subscription filter and was dropped. It now asks for `[...DataEventType.options]`. - `subscribeBulkData` kept the same kind of hand list (`['data.records.updated', 'data.records.deleted']`). It now asks for `[...BulkDataEventType.options]`; its two types are unchanged. No other client code lists data event types. - **Restored joins the default list rather than an opt-in.** The triage left that to the lane. A restore is the inverse of a delete the subscriber already receives, and the callback is typed `(event: DataEvent) => void`, whose `type` is `DataEventType`. Delivering the whole enum makes the subscription mean what its type says, and a list that rebuilds itself from created / deleted needs the restore to stay correct. - **No new dependency.** `realtime-api.ts` already imports runtime values (`DataEventSchema`, `BulkDataEventSchema`) from `@objectstack/spec/api`, so the two enums come from a module the client already loads. The published dependency set and the imported module are unchanged. - Changeset `31473-client-subscribe-data-restored.md`: `@objectstack/client` patch (`Metadata-protocol: none`, ruling A). ## Tests New `packages/client/src/realtime-api-data-event-types.test.ts`: - ⭐ a row restored from the recycle bin reappears in the subscriber's list: created → deleted → restored, the callback's list ends with the row. - ⭐ every `DataEventType` member reaches `subscribeData` (read from the enum, so a type added later is covered without editing the test). - every `BulkDataEventType` member reaches `subscribeBulkData` (green on main too; it keeps the bulk list honest from here on). - ⭐ `realtime-api.ts` spells no `data.record(s).*` type as a string literal and reads both enums. Red on main (2ae3e30, test file only): `Tests 3 failed | 1 passed (4)`: ``` × ⭐ a row restored from the recycle bin reappears in the subscriber's list AssertionError: expected [] to deeply equal [ 'task_1' ] × ⭐ every DataEventType the spec declares reaches subscribeData AssertionError: expected [ 'data.record.created', …(2) ] to deeply equal [ 'data.record.created', …(3) ] × ⭐ realtime-api.ts keeps no hand list of data event types ``` Ablation (`scripts/ablation-replace.mjs` on committed `ec765f5d`, `subscribeData`'s `eventTypes: [...DataEventType.options]` put back as the old three-item hand list): `ok mutation landed: anchor 1 -> 0, blob 444fe4c -> 5bcbff9269d0`, the same three pins red (`Tests 3 failed | 1 passed (4)`), then `ok restored: blob == HEAD (444fe4c) and git diff HEAD is empty`. ## Acceptance notes - The handler's comment still says `data.field.changed` is "declared in `DataEventType`". The enum no longer lists it. Left as is because it is outside this card's surface. Noted, not filed. ## Verification - Head `cba18d92`; cut from fresh origin/main `2ae3e301`, with origin/main `7d0b47ca` merged before opening (no file overlap with this diff; the three realtime test files re-ran green after the merge, 26/26). - `pnpm --filter @objectstack/client test` under `os-verify-lock`: `VERDICT command-exit 0`, Test Files 56 passed (56), Tests 716 passed (716). - `pnpm --filter @objectstack/client typecheck` (`tsc --noEmit` and `check:test-typecheck`): exit 0. `eslint` on the two touched `.ts` files: exit 0. - Gates from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 61 derived, 61 run, all exit 0 (`--ran`: 0 NOT-MEASURED). Two first read exit 3 (`check:skill-examples`, `check:dual-build-cjs-loads`: a prerequisite, built output missing in a fresh worktree). After `pnpm --filter './packages/**' run build` under the lock, both exited 0. Record: `/mnt/project-files/pm-orders/server/31473-gates-ran.txt`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the two touched `.ts` files: exit 0) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (`.changeset/31473-client-subscribe-data-restored.md`, `@objectstack/client` patch) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (the 维护者速读 block is the lane's required Chinese summary) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mbAyTfp7gbjowRA48 --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mbAyTfp7gbjowRA48)_ <!-- signed: domain:server#5 · os-project-manager · session_01FEkQ5mbAyTfp7gbjowRA48 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ec29753 commit 0c0ede0

3 files changed

Lines changed: 129 additions & 6 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/client': patch
3+
---
4+
5+
fix(client): `subscribeData` delivers `data.record.restored`, reading its event types from the spec
6+
7+
Clause-②: yes (widening) — `RealtimeAPI.subscribeData` now delivers every per-record event type the spec's `DataEventType` declares, which adds `data.record.restored` (a row restored from the recycle bin) to the created / updated / deleted it delivered before. `subscribeBulkData` reads `BulkDataEventType` the same way; its two types are unchanged.
8+
Metadata-protocol: none
9+
10+
`subscribeData` asked for a hand-written list of created / updated / deleted, so when `data.record.restored` joined `DataEventType` the subscription filtered it out, and a subscriber's live list never saw a restored row come back. Both data subscriptions now take their event types from the spec enums (`DataEventType.options`, `BulkDataEventType.options`), so a type the spec adds later reaches subscribers without a client edit. A callback that switches on `event.type` will now also see `data.record.restored`, whose payload carries the restored row in `after`.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #31473 — the SDK's data subscriptions ask for the event types the spec
5+
* declares, read from the spec, never from a hand list.
6+
*
7+
* `subscribeData` hand-listed created / updated / deleted, so when #31220
8+
* added `data.record.restored` to `DataEventType` (a row restored from the
9+
* recycle bin, published by ObjectQL's restore), the subscription filtered it
10+
* out and a subscriber's live list never saw the row come back.
11+
*
12+
* - ⭐ a `data.record.restored` event reaches the callback, and a list the
13+
* callback keeps shows the restored row again;
14+
* - ⭐ every member of the spec's `DataEventType` reaches `subscribeData`,
15+
* and every member of `BulkDataEventType` reaches `subscribeBulkData`
16+
* (read from the enums, so a type added later is covered without an edit
17+
* here);
18+
* - ⭐ `realtime-api.ts` spells no data event type as a string literal: the
19+
* lists come from the spec enums.
20+
*/
21+
22+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
23+
import { readFileSync } from 'node:fs';
24+
import { dirname, join } from 'node:path';
25+
import { fileURLToPath } from 'node:url';
26+
import type { RealtimeEventPayload } from '@objectstack/spec/contracts';
27+
import { BulkDataEventType, DataEventType, type DataEvent } from '@objectstack/spec/api';
28+
import { RealtimeAPI } from './realtime-api';
29+
30+
const HERE = dirname(fileURLToPath(import.meta.url));
31+
const AT = '2026-11-08T12:00:00.000Z';
32+
33+
function recordEvent(type: string, recordId: string, after?: Record<string, unknown>): RealtimeEventPayload {
34+
const payload = {
35+
id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
36+
type,
37+
object: 'project_task',
38+
recordId,
39+
...(after ? { after } : {}),
40+
timestamp: AT,
41+
};
42+
return { type, object: 'project_task', payload, timestamp: AT };
43+
}
44+
45+
function bulkEvent(type: string): RealtimeEventPayload {
46+
const payload = { id: '3f2504e0-4f89-41d3-9a0c-0305e82c3301', type, object: 'project_task', matched: 3, timestamp: AT };
47+
return { type, object: 'project_task', payload, timestamp: AT };
48+
}
49+
50+
describe('#31473 — the data subscriptions follow the spec event enums', () => {
51+
let api: RealtimeAPI;
52+
53+
beforeEach(() => {
54+
vi.useFakeTimers();
55+
api = new RealtimeAPI('http://localhost:3000');
56+
});
57+
58+
afterEach(() => {
59+
api.disconnect();
60+
vi.useRealTimers();
61+
vi.restoreAllMocks();
62+
});
63+
64+
function deliver(envelope: RealtimeEventPayload): void {
65+
api._bufferEvent(envelope);
66+
vi.advanceTimersByTime(2000); // the poll interval drains the buffer
67+
}
68+
69+
it('⭐ a row restored from the recycle bin reappears in the subscriber\'s list', () => {
70+
const rows = new Map<string, Record<string, unknown>>();
71+
const apply = (event: DataEvent): void => {
72+
if (event.type === 'data.record.deleted') rows.delete(event.recordId);
73+
else if (event.after) rows.set(event.recordId, event.after);
74+
};
75+
api.subscribeData('project_task', apply);
76+
77+
deliver(recordEvent('data.record.created', 'task_1', { id: 'task_1', title: 'Ship it' }));
78+
deliver(recordEvent('data.record.deleted', 'task_1'));
79+
expect([...rows.keys()]).toEqual([]);
80+
81+
deliver(recordEvent('data.record.restored', 'task_1', { id: 'task_1', title: 'Ship it' }));
82+
expect([...rows.keys()]).toEqual(['task_1']);
83+
});
84+
85+
it('⭐ every DataEventType the spec declares reaches subscribeData', () => {
86+
const seen: string[] = [];
87+
api.subscribeData('project_task', (event) => seen.push(event.type));
88+
89+
for (const type of DataEventType.options) deliver(recordEvent(type, 'task_1'));
90+
91+
expect(seen).toEqual([...DataEventType.options]);
92+
expect(seen).toContain('data.record.restored');
93+
});
94+
95+
it('⭐ every BulkDataEventType the spec declares reaches subscribeBulkData', () => {
96+
const seen: string[] = [];
97+
api.subscribeBulkData('project_task', (event) => seen.push(event.type));
98+
99+
for (const type of BulkDataEventType.options) deliver(bulkEvent(type));
100+
101+
expect(seen).toEqual([...BulkDataEventType.options]);
102+
});
103+
104+
it('⭐ realtime-api.ts keeps no hand list of data event types', () => {
105+
const source = readFileSync(join(HERE, 'realtime-api.ts'), 'utf8');
106+
expect(source).not.toMatch(/'data\.records?\.[a-z_]+'/);
107+
expect(source).toMatch(/DataEventType\.options/);
108+
expect(source).toMatch(/BulkDataEventType\.options/);
109+
});
110+
});

‎packages/client/src/realtime-api.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
MetadataEventSchema,
1313
DataEventSchema,
1414
BulkDataEventSchema,
15+
DataEventType,
16+
BulkDataEventType,
1517
type MetadataEvent,
1618
type MetadataEventSubject,
1719
type MetadataEventType,
@@ -142,11 +144,11 @@ export class RealtimeAPI {
142144
filter: {
143145
type: object,
144146
recordId: options?.recordId,
145-
eventTypes: [
146-
'data.record.created',
147-
'data.record.updated',
148-
'data.record.deleted'
149-
]
147+
// [#31473] Read from the spec's enum, never hand-listed: a hand list
148+
// of created / updated / deleted silently dropped
149+
// `data.record.restored` when #31220 added it, so a row restored from
150+
// the recycle bin never reached the subscriber.
151+
eventTypes: [...DataEventType.options]
150152
},
151153
handler: (event) => {
152154
if (!event.type.startsWith('data.') || event.object !== object) return;
@@ -224,7 +226,8 @@ export class RealtimeAPI {
224226
this.subscriptions.set(subscriptionId, {
225227
filter: {
226228
type: object,
227-
eventTypes: ['data.records.updated', 'data.records.deleted']
229+
// [#31473] Read from the spec's enum, like subscribeData's list.
230+
eventTypes: [...BulkDataEventType.options]
228231
},
229232
handler: (event) => {
230233
if (!event.type.startsWith('data.records.') || event.object !== object) return;

0 commit comments

Comments
 (0)