Skip to content

Commit 39b89f9

Browse files
authored
Fix database collab IndexedDB cache identity (#332)
* fix: use canonical cache for database collabs * fix: render board fallback group columns * fix: resolve database id before publishing
1 parent 21091f9 commit 39b89f9

15 files changed

Lines changed: 1325 additions & 58 deletions

File tree

playwright/e2e/database/database-collab-cache.spec.ts

Lines changed: 445 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { expect } from '@jest/globals';
2+
import * as Y from 'yjs';
3+
4+
import { openCollabDB, openCollabDBWithProvider } from '@/application/db';
5+
import { fetchPageCollab } from '@/application/services/js-services/fetch';
6+
import { enqueueOutboxUpdate } from '@/application/sync-outbox';
7+
import { Types, ViewLayout, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types';
8+
import { getDatabaseIdFromDoc, openView } from '@/application/view-loader';
9+
10+
jest.mock('@/application/db', () => ({
11+
openCollabDB: jest.fn(),
12+
openCollabDBWithProvider: jest.fn(),
13+
}));
14+
15+
jest.mock('@/application/services/js-services/cache', () => ({
16+
getOrCreateRowSubDoc: jest.fn(),
17+
hasCollabCache: jest.fn((doc: YDoc) => {
18+
const root = doc.getMap(YjsEditorKey.data_section);
19+
20+
return root.has(YjsEditorKey.database) || root.has(YjsEditorKey.document);
21+
}),
22+
}));
23+
24+
jest.mock('@/application/services/js-services/fetch', () => ({
25+
fetchPageCollab: jest.fn(),
26+
}));
27+
28+
jest.mock('@/application/sync-outbox', () => ({
29+
enqueueOutboxUpdate: jest.fn(),
30+
}));
31+
32+
const mockOpenCollabDB = openCollabDB as jest.MockedFunction<typeof openCollabDB>;
33+
const mockOpenCollabDBWithProvider = openCollabDBWithProvider as jest.MockedFunction<typeof openCollabDBWithProvider>;
34+
const mockFetchPageCollab = fetchPageCollab as jest.MockedFunction<typeof fetchPageCollab>;
35+
const mockEnqueueOutboxUpdate = enqueueOutboxUpdate as jest.MockedFunction<typeof enqueueOutboxUpdate>;
36+
37+
function createEmptyDoc(guid: string): YDoc {
38+
return new Y.Doc({ guid }) as YDoc;
39+
}
40+
41+
function createDatabaseDoc(guid: string, databaseId = guid): YDoc {
42+
const doc = createEmptyDoc(guid);
43+
const root = doc.getMap(YjsEditorKey.data_section);
44+
const database = new Y.Map();
45+
46+
database.set(YjsDatabaseKey.id, databaseId);
47+
root.set(YjsEditorKey.database, database);
48+
return doc;
49+
}
50+
51+
function createProvider(doc: YDoc) {
52+
return {
53+
doc,
54+
provider: {
55+
destroy: jest.fn().mockResolvedValue(undefined),
56+
synced: true,
57+
},
58+
};
59+
}
60+
61+
describe('view-loader database cache identity', () => {
62+
beforeEach(() => {
63+
jest.clearAllMocks();
64+
});
65+
66+
it('opens database views from the canonical databaseId cache and migrates legacy viewId data', async () => {
67+
const viewId = '00000000-0000-4000-8000-000000000001';
68+
const databaseId = '00000000-0000-4000-8000-000000000002';
69+
const canonicalDoc = createEmptyDoc(databaseId);
70+
const legacyDoc = createDatabaseDoc(viewId, databaseId);
71+
const docs = new Map([
72+
[databaseId, canonicalDoc],
73+
[viewId, legacyDoc],
74+
]);
75+
76+
mockOpenCollabDBWithProvider.mockImplementation(async (name: string) => {
77+
const doc = docs.get(name);
78+
79+
if (!doc) throw new Error(`Unexpected open ${name}`);
80+
return createProvider(doc) as never;
81+
});
82+
83+
const result = await openView('workspace-id', viewId, ViewLayout.Grid, { databaseId });
84+
85+
expect(result.doc).toBe(canonicalDoc);
86+
expect(result.fromCache).toBe(true);
87+
expect(result.collabType).toBe(Types.Database);
88+
expect(getDatabaseIdFromDoc(canonicalDoc)).toBe(databaseId);
89+
expect(mockOpenCollabDBWithProvider).toHaveBeenCalledWith(databaseId, { awaitSync: true });
90+
expect(mockOpenCollabDBWithProvider).toHaveBeenCalledWith(viewId, { skipCache: true });
91+
expect(mockEnqueueOutboxUpdate).toHaveBeenCalledWith(expect.objectContaining({
92+
objectId: databaseId,
93+
collabType: Types.Database,
94+
payload: expect.any(Uint8Array),
95+
}));
96+
expect(mockFetchPageCollab).not.toHaveBeenCalled();
97+
});
98+
99+
it('fetches by viewId into the canonical databaseId cache when local cache is empty', async () => {
100+
const viewId = '00000000-0000-4000-8000-000000000003';
101+
const databaseId = '00000000-0000-4000-8000-000000000004';
102+
const canonicalDoc = createEmptyDoc(databaseId);
103+
const legacyDoc = createEmptyDoc(viewId);
104+
const serverDoc = createDatabaseDoc(databaseId);
105+
const docs = new Map([
106+
[databaseId, canonicalDoc],
107+
[viewId, legacyDoc],
108+
]);
109+
110+
mockOpenCollabDBWithProvider.mockImplementation(async (name: string) => {
111+
const doc = docs.get(name);
112+
113+
if (!doc) throw new Error(`Unexpected open ${name}`);
114+
return createProvider(doc) as never;
115+
});
116+
mockOpenCollabDB.mockImplementation(async (name: string) => {
117+
const doc = docs.get(name);
118+
119+
if (!doc) throw new Error(`Unexpected open ${name}`);
120+
return doc;
121+
});
122+
mockFetchPageCollab.mockResolvedValue({
123+
data: Y.encodeStateAsUpdate(serverDoc),
124+
rows: {},
125+
});
126+
127+
const result = await openView('workspace-id', viewId, ViewLayout.Grid, { databaseId });
128+
129+
expect(result.doc).toBe(canonicalDoc);
130+
expect(result.fromCache).toBe(false);
131+
expect(getDatabaseIdFromDoc(canonicalDoc)).toBe(databaseId);
132+
expect(mockFetchPageCollab).toHaveBeenCalledWith('workspace-id', viewId);
133+
});
134+
135+
it('uses the canonical databaseId cache when the database layout was discovered after the first load', async () => {
136+
const viewId = '00000000-0000-4000-8000-000000000005';
137+
const databaseId = '00000000-0000-4000-8000-000000000006';
138+
const canonicalDoc = createEmptyDoc(databaseId);
139+
const legacyDoc = createDatabaseDoc(viewId, databaseId);
140+
const docs = new Map([
141+
[databaseId, canonicalDoc],
142+
[viewId, legacyDoc],
143+
]);
144+
145+
mockOpenCollabDBWithProvider.mockImplementation(async (name: string) => {
146+
const doc = docs.get(name);
147+
148+
if (!doc) throw new Error(`Unexpected open ${name}`);
149+
return createProvider(doc) as never;
150+
});
151+
152+
const result = await openView('workspace-id', viewId, undefined, { databaseId });
153+
154+
expect(result.doc).toBe(canonicalDoc);
155+
expect(result.fromCache).toBe(true);
156+
expect(getDatabaseIdFromDoc(canonicalDoc)).toBe(databaseId);
157+
});
158+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { renderHook, waitFor } from '@testing-library/react';
2+
import type React from 'react';
3+
import * as Y from 'yjs';
4+
5+
import { DatabaseContext, DatabaseContextState, FieldType, useGroup } from '@/application/database-yjs';
6+
import { YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types';
7+
8+
jest.mock('@/utils/runtime-config', () => ({
9+
getConfigValue: (_key: string, fallback: string) => fallback,
10+
}));
11+
12+
function createDatabaseDoc({
13+
fieldId,
14+
groupId,
15+
groupColumns,
16+
viewId,
17+
}: {
18+
fieldId: string;
19+
groupId: string;
20+
groupColumns: unknown[];
21+
viewId: string;
22+
}): YDoc {
23+
const doc = new Y.Doc() as unknown as YDoc;
24+
const sharedRoot = doc.getMap(YjsEditorKey.data_section);
25+
const database = new Y.Map();
26+
const fields = new Y.Map();
27+
const field = new Y.Map();
28+
const views = new Y.Map();
29+
const view = new Y.Map();
30+
const groups = new Y.Array();
31+
const group = new Y.Map();
32+
const columns = new Y.Array();
33+
34+
field.set(YjsDatabaseKey.id, fieldId);
35+
field.set(YjsDatabaseKey.type, FieldType.SingleSelect);
36+
fields.set(fieldId, field);
37+
38+
columns.push(groupColumns);
39+
group.set(YjsDatabaseKey.id, groupId);
40+
group.set(YjsDatabaseKey.field_id, fieldId);
41+
group.set(YjsDatabaseKey.type, FieldType.SingleSelect);
42+
group.set(YjsDatabaseKey.groups, columns);
43+
groups.push([group]);
44+
45+
view.set(YjsDatabaseKey.groups, groups);
46+
views.set(viewId, view);
47+
48+
database.set(YjsDatabaseKey.id, 'database-id');
49+
database.set(YjsDatabaseKey.fields, fields);
50+
database.set(YjsDatabaseKey.views, views);
51+
sharedRoot.set(YjsEditorKey.database, database);
52+
53+
return doc;
54+
}
55+
56+
function createWrapper(databaseDoc: YDoc, activeViewId: string) {
57+
const contextValue: DatabaseContextState = {
58+
readOnly: false,
59+
databaseDoc,
60+
databasePageId: activeViewId,
61+
activeViewId,
62+
rowMap: null,
63+
workspaceId: 'workspace-id',
64+
};
65+
66+
return ({ children }: { children: React.ReactNode }) => (
67+
<DatabaseContext.Provider value={contextValue}>{children}</DatabaseContext.Provider>
68+
);
69+
}
70+
71+
describe('useGroup', () => {
72+
it('falls back to default group columns when persisted board columns are empty', async () => {
73+
const fieldId = 'field-id';
74+
const groupId = 'group-id';
75+
const viewId = 'board-view-id';
76+
const databaseDoc = createDatabaseDoc({
77+
fieldId,
78+
groupId,
79+
groupColumns: [],
80+
viewId,
81+
});
82+
83+
const { result } = renderHook(() => useGroup(groupId), {
84+
wrapper: createWrapper(databaseDoc, viewId),
85+
});
86+
87+
await waitFor(() => {
88+
expect(result.current.fieldId).toBe(fieldId);
89+
});
90+
91+
expect(result.current.columns).toEqual([{ id: fieldId, visible: true }]);
92+
});
93+
94+
it('normalizes Y.Map group columns from persisted collab data', async () => {
95+
const fieldId = 'field-id';
96+
const groupId = 'group-id';
97+
const optionId = 'option-id';
98+
const viewId = 'board-view-id';
99+
const column = new Y.Map();
100+
101+
column.set(YjsDatabaseKey.id, optionId);
102+
column.set(YjsDatabaseKey.visible, false);
103+
104+
const databaseDoc = createDatabaseDoc({
105+
fieldId,
106+
groupId,
107+
groupColumns: [column],
108+
viewId,
109+
});
110+
111+
const { result } = renderHook(() => useGroup(groupId), {
112+
wrapper: createWrapper(databaseDoc, viewId),
113+
});
114+
115+
await waitFor(() => {
116+
expect(result.current.fieldId).toBe(fieldId);
117+
});
118+
119+
expect(result.current.columns).toEqual([{ id: optionId, visible: false }]);
120+
});
121+
});

src/application/database-yjs/selector.ts

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
SelectOption,
2626
} from '@/application/database-yjs/fields';
2727
import { filterBy, flattenFilterTree, parseFilter } from '@/application/database-yjs/filter';
28-
import { groupByField } from '@/application/database-yjs/group';
28+
import { getGroupColumns, groupByField } from '@/application/database-yjs/group';
2929
import { useBackgroundRowDocLoader, useRollupFieldObservers } from '@/application/database-yjs/hooks';
3030
import {
3131
invalidateRelationCell,
@@ -892,10 +892,47 @@ export interface GroupColumn {
892892
visible: boolean;
893893
}
894894

895+
function normalizeGroupColumn(column: unknown): GroupColumn | null {
896+
const parseVisible = (value: unknown) => value !== false && value !== 'false';
897+
898+
if (!column || typeof column !== 'object') return null;
899+
900+
if ('get' in column && typeof column.get === 'function') {
901+
const mapColumn = column as { get: (key: YjsDatabaseKey) => unknown };
902+
const id = mapColumn.get(YjsDatabaseKey.id);
903+
904+
if (typeof id !== 'string' || !id) return null;
905+
906+
return {
907+
id,
908+
visible: parseVisible(mapColumn.get(YjsDatabaseKey.visible)),
909+
};
910+
}
911+
912+
const plainColumn = column as Partial<GroupColumn>;
913+
914+
if (typeof plainColumn.id !== 'string' || !plainColumn.id) return null;
915+
916+
return {
917+
id: plainColumn.id,
918+
visible: parseVisible(plainColumn.visible),
919+
};
920+
}
921+
922+
function getFallbackGroupColumns(field?: YDatabaseField): GroupColumn[] {
923+
if (!field) return [];
924+
925+
return (getGroupColumns(field) ?? []).map((column) => ({
926+
id: column.id,
927+
visible: true,
928+
}));
929+
}
930+
895931
export function useGroup(groupId: string) {
896932
const database = useDatabase();
897933
const viewId = useDatabaseViewId();
898934
const view = database?.get(YjsDatabaseKey.views)?.get(viewId);
935+
const fields = database?.get(YjsDatabaseKey.fields);
899936
const group = view
900937
?.get(YjsDatabaseKey.groups)
901938
?.toArray()
@@ -911,36 +948,24 @@ export function useGroup(groupId: string) {
911948

912949
setFieldId(groupFieldId);
913950
const groupColumnsVisible = group.get(YjsDatabaseKey.groups);
914-
const rawArray = groupColumnsVisible?.toArray() || [];
915-
// Cloud serializes each inner group column as a Y.Map (id/visible
916-
// keys), not a plain object literal — the array's type signature
917-
// misrepresents this. Materialize to plain `GroupColumn`s so
918-
// downstream consumers can read `.id` / `.visible` directly.
919-
const visibleArray: GroupColumn[] = rawArray.map((item) => {
920-
const maybeYMap = item as unknown as {
921-
get?: (key: string) => unknown;
922-
};
923-
924-
if (typeof maybeYMap.get === 'function') {
925-
return {
926-
id: String(maybeYMap.get('id') ?? ''),
927-
visible: Boolean(maybeYMap.get('visible')),
928-
};
929-
}
930-
931-
return item as GroupColumn;
932-
});
951+
const persistedColumns = (groupColumnsVisible?.toArray() ?? [])
952+
.map(normalizeGroupColumn)
953+
.filter((column): column is GroupColumn => Boolean(column));
933954

934-
setColumns(visibleArray);
955+
setColumns(
956+
persistedColumns.length > 0 ? persistedColumns : getFallbackGroupColumns(fields?.get(groupFieldId))
957+
);
935958
};
936959

937960
observerEvent();
938961
group?.observeDeep(observerEvent);
962+
fields?.observeDeep(observerEvent);
939963

940964
return () => {
941965
group?.unobserveDeep(observerEvent);
966+
fields?.unobserveDeep(observerEvent);
942967
};
943-
}, [database, viewId, groupId, group]);
968+
}, [viewId, groupId, group, fields]);
944969

945970
return {
946971
columns,

0 commit comments

Comments
 (0)