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
10 changes: 10 additions & 0 deletions .changeset/31473-client-subscribe-data-restored.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/client': patch
---

fix(client): `subscribeData` delivers `data.record.restored`, reading its event types from the spec

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

`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`.
110 changes: 110 additions & 0 deletions packages/client/src/realtime-api-data-event-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #31473 — the SDK's data subscriptions ask for the event types the spec
* declares, read from the spec, never from a hand list.
*
* `subscribeData` hand-listed created / updated / deleted, so when #31220
* added `data.record.restored` to `DataEventType` (a row restored from the
* recycle bin, published by ObjectQL's restore), the subscription filtered it
* out and a subscriber's live list never saw the row come back.
*
* - ⭐ a `data.record.restored` event reaches the callback, and a list the
* callback keeps shows the restored row again;
* - ⭐ every member of the spec's `DataEventType` reaches `subscribeData`,
* and every member of `BulkDataEventType` reaches `subscribeBulkData`
* (read from the enums, so a type added later is covered without an edit
* here);
* - ⭐ `realtime-api.ts` spells no data event type as a string literal: the
* lists come from the spec enums.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { RealtimeEventPayload } from '@objectstack/spec/contracts';
import { BulkDataEventType, DataEventType, type DataEvent } from '@objectstack/spec/api';
import { RealtimeAPI } from './realtime-api';

const HERE = dirname(fileURLToPath(import.meta.url));
const AT = '2026-11-08T12:00:00.000Z';

function recordEvent(type: string, recordId: string, after?: Record<string, unknown>): RealtimeEventPayload {
const payload = {
id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
type,
object: 'project_task',
recordId,
...(after ? { after } : {}),
timestamp: AT,
};
return { type, object: 'project_task', payload, timestamp: AT };
}

function bulkEvent(type: string): RealtimeEventPayload {
const payload = { id: '3f2504e0-4f89-41d3-9a0c-0305e82c3301', type, object: 'project_task', matched: 3, timestamp: AT };
return { type, object: 'project_task', payload, timestamp: AT };
}

describe('#31473 — the data subscriptions follow the spec event enums', () => {
let api: RealtimeAPI;

beforeEach(() => {
vi.useFakeTimers();
api = new RealtimeAPI('http://localhost:3000');
});

afterEach(() => {
api.disconnect();
vi.useRealTimers();
vi.restoreAllMocks();
});

function deliver(envelope: RealtimeEventPayload): void {
api._bufferEvent(envelope);
vi.advanceTimersByTime(2000); // the poll interval drains the buffer
}

it('⭐ a row restored from the recycle bin reappears in the subscriber\'s list', () => {
const rows = new Map<string, Record<string, unknown>>();
const apply = (event: DataEvent): void => {
if (event.type === 'data.record.deleted') rows.delete(event.recordId);
else if (event.after) rows.set(event.recordId, event.after);
};
api.subscribeData('project_task', apply);

deliver(recordEvent('data.record.created', 'task_1', { id: 'task_1', title: 'Ship it' }));
deliver(recordEvent('data.record.deleted', 'task_1'));
expect([...rows.keys()]).toEqual([]);

deliver(recordEvent('data.record.restored', 'task_1', { id: 'task_1', title: 'Ship it' }));
expect([...rows.keys()]).toEqual(['task_1']);
});

it('⭐ every DataEventType the spec declares reaches subscribeData', () => {
const seen: string[] = [];
api.subscribeData('project_task', (event) => seen.push(event.type));

for (const type of DataEventType.options) deliver(recordEvent(type, 'task_1'));

expect(seen).toEqual([...DataEventType.options]);
expect(seen).toContain('data.record.restored');
});

it('⭐ every BulkDataEventType the spec declares reaches subscribeBulkData', () => {
const seen: string[] = [];
api.subscribeBulkData('project_task', (event) => seen.push(event.type));

for (const type of BulkDataEventType.options) deliver(bulkEvent(type));

expect(seen).toEqual([...BulkDataEventType.options]);
});

it('⭐ realtime-api.ts keeps no hand list of data event types', () => {
const source = readFileSync(join(HERE, 'realtime-api.ts'), 'utf8');
expect(source).not.toMatch(/'data\.records?\.[a-z_]+'/);
expect(source).toMatch(/DataEventType\.options/);
expect(source).toMatch(/BulkDataEventType\.options/);
});
});
15 changes: 9 additions & 6 deletions packages/client/src/realtime-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
MetadataEventSchema,
DataEventSchema,
BulkDataEventSchema,
DataEventType,
BulkDataEventType,
type MetadataEvent,
type MetadataEventSubject,
type MetadataEventType,
Expand Down Expand Up @@ -142,11 +144,11 @@ export class RealtimeAPI {
filter: {
type: object,
recordId: options?.recordId,
eventTypes: [
'data.record.created',
'data.record.updated',
'data.record.deleted'
]
// [#31473] Read from the spec's enum, never hand-listed: a hand list
// of created / updated / deleted silently dropped
// `data.record.restored` when #31220 added it, so a row restored from
// the recycle bin never reached the subscriber.
eventTypes: [...DataEventType.options]
},
handler: (event) => {
if (!event.type.startsWith('data.') || event.object !== object) return;
Expand Down Expand Up @@ -224,7 +226,8 @@ export class RealtimeAPI {
this.subscriptions.set(subscriptionId, {
filter: {
type: object,
eventTypes: ['data.records.updated', 'data.records.deleted']
// [#31473] Read from the spec's enum, like subscribeData's list.
eventTypes: [...BulkDataEventType.options]
},
handler: (event) => {
if (!event.type.startsWith('data.records.') || event.object !== object) return;
Expand Down
Loading