@svadmin/supabase 0.9.3 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +16 -2
- package/src/data-provider.ts +1 -0
- package/src/live-provider.ts +24 -1
- package/src/supabase.test.ts +214 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/supabase",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"description": "Supabase adapters — DataProvider, AuthProvider, LiveProvider, AuditHandler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"@svadmin/core": "^0.
|
|
19
|
+
"@svadmin/core": "^0.23.3",
|
|
20
20
|
"@supabase/supabase-js": "^2.0.0",
|
|
21
21
|
"@refinedev/supabase": "^5.0.0",
|
|
22
22
|
"@svadmin/refine-adapter": "^0.9.3"
|
|
@@ -31,5 +31,19 @@
|
|
|
31
31
|
"homepage": "https://github.com/zuohuadong/svadmin#readme",
|
|
32
32
|
"bugs": {
|
|
33
33
|
"url": "https://github.com/zuohuadong/svadmin/issues"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"@svadmin/core": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
39
|
+
"@supabase/supabase-js": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"@refinedev/supabase": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"@svadmin/refine-adapter": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
34
48
|
}
|
|
35
49
|
}
|
package/src/data-provider.ts
CHANGED
|
@@ -11,6 +11,7 @@ export async function createSupabaseDataProvider(...args: any[]): Promise<DataPr
|
|
|
11
11
|
// @ts-ignore Peer dependency
|
|
12
12
|
const pkg = (await import('@refinedev/supabase')) as any;
|
|
13
13
|
const init: any = pkg.dataProvider || pkg.default || pkg.DataProvider;
|
|
14
|
+
if (typeof init !== 'function') throw new Error('[svadmin] Failed to resolve @refinedev/supabase data provider. Ensure the package is installed correctly.');
|
|
14
15
|
const refineProvider = init(...args);
|
|
15
16
|
return createRefineAdapter(refineProvider);
|
|
16
17
|
}
|
package/src/live-provider.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { LiveProvider, LiveEvent } from '@svadmin/core';
|
|
|
5
5
|
export function createSupabaseLiveProvider(client: SupabaseClient): LiveProvider {
|
|
6
6
|
const subscribers = new Map<string, Set<(event: LiveEvent) => void>>();
|
|
7
7
|
const channels = new Map<string, RealtimeChannel>();
|
|
8
|
+
const broadcastChannels = new Map<string, RealtimeChannel>();
|
|
8
9
|
|
|
9
10
|
function getOrCreateChannel(resource: string): RealtimeChannel {
|
|
10
11
|
let channel = channels.get(resource);
|
|
@@ -33,6 +34,23 @@ export function createSupabaseLiveProvider(client: SupabaseClient): LiveProvider
|
|
|
33
34
|
return channel;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
function getOrCreateBroadcastChannel(resource: string): RealtimeChannel {
|
|
38
|
+
let channel = broadcastChannels.get(resource);
|
|
39
|
+
if (!channel) {
|
|
40
|
+
const existing = channels.get(resource);
|
|
41
|
+
if (existing) return existing;
|
|
42
|
+
channel = client.channel(`live-bc-${resource}`)
|
|
43
|
+
.on('broadcast', { event: 'live-event' }, ({ payload }) => {
|
|
44
|
+
if (payload) {
|
|
45
|
+
subscribers.get(resource)?.forEach(cb => cb(payload as LiveEvent));
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
.subscribe();
|
|
49
|
+
broadcastChannels.set(resource, channel);
|
|
50
|
+
}
|
|
51
|
+
return channel;
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
return {
|
|
37
55
|
subscribe({ resource, callback }) {
|
|
38
56
|
if (!subscribers.has(resource)) subscribers.set(resource, new Set());
|
|
@@ -51,13 +69,18 @@ export function createSupabaseLiveProvider(client: SupabaseClient): LiveProvider
|
|
|
51
69
|
channel.unsubscribe();
|
|
52
70
|
channels.delete(resource);
|
|
53
71
|
}
|
|
72
|
+
const bc = broadcastChannels.get(resource);
|
|
73
|
+
if (bc) {
|
|
74
|
+
bc.unsubscribe();
|
|
75
|
+
broadcastChannels.delete(resource);
|
|
76
|
+
}
|
|
54
77
|
}
|
|
55
78
|
}
|
|
56
79
|
};
|
|
57
80
|
},
|
|
58
81
|
|
|
59
82
|
publish(event: LiveEvent) {
|
|
60
|
-
const channel =
|
|
83
|
+
const channel = getOrCreateBroadcastChannel(event.resource);
|
|
61
84
|
channel.send({
|
|
62
85
|
type: 'broadcast',
|
|
63
86
|
event: 'live-event',
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// @svadmin/supabase — Unit Tests
|
|
2
|
+
import { describe, test, expect, mock } from 'bun:test';
|
|
3
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
4
|
+
|
|
5
|
+
// ─── Mock @svadmin/core ───────────────────────────────────────────
|
|
6
|
+
mock.module('@svadmin/core', () => {
|
|
7
|
+
return {
|
|
8
|
+
audit: mock(() => {}),
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// ─── Mock Refine Supabase Provider ────────────────────────────────
|
|
13
|
+
mock.module('@refinedev/supabase', () => {
|
|
14
|
+
return {
|
|
15
|
+
dataProvider: (..._args: any[]) => {
|
|
16
|
+
const mockDp: any = {
|
|
17
|
+
getList: async () => ({ data: [{ id: 1, title: 'Item 1' }], total: 1 }),
|
|
18
|
+
getOne: async () => ({ data: { id: 1, title: 'Item 1' } }),
|
|
19
|
+
create: async () => ({ data: { id: 2, title: 'New Item' } }),
|
|
20
|
+
update: async () => ({ data: { id: 1, title: 'Updated' } }),
|
|
21
|
+
deleteOne: async () => ({ data: { id: 1, title: 'Deleted' } }),
|
|
22
|
+
};
|
|
23
|
+
return mockDp;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ─── Mock Supabase Client ──────────────────────────────────────────
|
|
29
|
+
function createMockSupabaseClient(overrides: Record<string, any> = {}) {
|
|
30
|
+
const client = {
|
|
31
|
+
auth: {
|
|
32
|
+
signInWithPassword: mock(async ({ email, password }) => {
|
|
33
|
+
if (password === 'bad') return { error: { message: 'Invalid credentials' } };
|
|
34
|
+
return { data: { user: { id: 'user-1' } }, error: null };
|
|
35
|
+
}),
|
|
36
|
+
signOut: mock(async () => ({ error: null })),
|
|
37
|
+
getSession: mock(async () => ({ data: { session: { access_token: 'valid-token' } } })),
|
|
38
|
+
getUser: mock(async () => ({
|
|
39
|
+
data: {
|
|
40
|
+
user: {
|
|
41
|
+
id: 'user-1',
|
|
42
|
+
email: 'admin@test.com',
|
|
43
|
+
user_metadata: { name: 'Admin', avatar_url: 'http://avatar', role: 'admin' }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
})),
|
|
47
|
+
signUp: mock(async ({ email }) => {
|
|
48
|
+
if (email === 'bad@test.com') return { error: { message: 'Signup failed' } };
|
|
49
|
+
return { error: null };
|
|
50
|
+
}),
|
|
51
|
+
resetPasswordForEmail: mock(async () => ({ error: null })),
|
|
52
|
+
updateUser: mock(async () => ({ error: null })),
|
|
53
|
+
...(overrides.auth || {}),
|
|
54
|
+
},
|
|
55
|
+
channel: mock((name: string) => {
|
|
56
|
+
const c: any = {
|
|
57
|
+
name,
|
|
58
|
+
on: mock(() => c),
|
|
59
|
+
subscribe: mock(() => c),
|
|
60
|
+
unsubscribe: mock(() => c),
|
|
61
|
+
send: mock(() => c)
|
|
62
|
+
};
|
|
63
|
+
return c;
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
// If there are top-level overrides besides auth, merge them
|
|
67
|
+
for (const [key, val] of Object.entries(overrides)) {
|
|
68
|
+
if (key !== 'auth') (client as any)[key] = val;
|
|
69
|
+
}
|
|
70
|
+
return client as unknown as SupabaseClient;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
// ─── DataProvider Tests ──────────────────────────────────────────
|
|
75
|
+
describe('Supabase DataProvider', () => {
|
|
76
|
+
test('getList routes through refine-adapter', async () => {
|
|
77
|
+
const { createSupabaseDataProvider } = await import('./data-provider');
|
|
78
|
+
const dp = await createSupabaseDataProvider({} as any);
|
|
79
|
+
const result = await dp.getList({ resource: 'posts' });
|
|
80
|
+
expect(result.data).toHaveLength(1);
|
|
81
|
+
expect(result.total).toBe(1);
|
|
82
|
+
expect(result.data[0].id).toBe(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('create returns new record', async () => {
|
|
86
|
+
const { createSupabaseDataProvider } = await import('./data-provider');
|
|
87
|
+
const dp = await createSupabaseDataProvider({} as any);
|
|
88
|
+
const result = await dp.create({ resource: 'posts', variables: { title: 'New Item' } });
|
|
89
|
+
expect(result.data.id).toBe(2);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
// ─── AuthProvider Tests ──────────────────────────────────────────
|
|
95
|
+
describe('Supabase AuthProvider', () => {
|
|
96
|
+
test('login success', async () => {
|
|
97
|
+
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
98
|
+
const auth = createSupabaseAuthProvider(createMockSupabaseClient());
|
|
99
|
+
const result = await auth.login({ email: 'admin@test.com', password: 'pass' });
|
|
100
|
+
expect(result.success).toBe(true);
|
|
101
|
+
expect(result.redirectTo).toBe('/');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('login failure', async () => {
|
|
105
|
+
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
106
|
+
const auth = createSupabaseAuthProvider(createMockSupabaseClient());
|
|
107
|
+
const result = await auth.login({ email: 'admin@test.com', password: 'bad' });
|
|
108
|
+
expect(result.success).toBe(false);
|
|
109
|
+
expect(result.error?.message).toBe('Invalid credentials');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('logout success', async () => {
|
|
113
|
+
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
114
|
+
const auth = createSupabaseAuthProvider(createMockSupabaseClient());
|
|
115
|
+
const result = await auth.logout();
|
|
116
|
+
expect(result.success).toBe(true);
|
|
117
|
+
expect(result.redirectTo).toBe('/login');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('check returns authenticated when session exists', async () => {
|
|
121
|
+
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
122
|
+
const auth = createSupabaseAuthProvider(createMockSupabaseClient());
|
|
123
|
+
const result = await auth.check();
|
|
124
|
+
expect(result.authenticated).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('check returns unauthenticated when no session', async () => {
|
|
128
|
+
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
129
|
+
const auth = createSupabaseAuthProvider(createMockSupabaseClient({
|
|
130
|
+
auth: { getSession: mock(async () => ({ data: { session: null } })) }
|
|
131
|
+
}));
|
|
132
|
+
const result = await auth.check();
|
|
133
|
+
expect(result.authenticated).toBe(false);
|
|
134
|
+
expect(result.redirectTo).toBe('/login');
|
|
135
|
+
expect(result.logout).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('getIdentity surfaces user metadata', async () => {
|
|
139
|
+
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
140
|
+
const auth = createSupabaseAuthProvider(createMockSupabaseClient());
|
|
141
|
+
const identity = await auth.getIdentity();
|
|
142
|
+
expect(identity).not.toBeNull();
|
|
143
|
+
expect(identity!.id).toBe('user-1');
|
|
144
|
+
expect(identity!.name).toBe('Admin');
|
|
145
|
+
expect(identity!.avatar).toBe('http://avatar');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('getPermissions surfaces role from user_metadata', async () => {
|
|
149
|
+
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
150
|
+
const auth = createSupabaseAuthProvider(createMockSupabaseClient());
|
|
151
|
+
const role = await auth.getPermissions?.();
|
|
152
|
+
expect(role).toBe('admin');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('onError returns logout true on 401 with no session', async () => {
|
|
156
|
+
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
157
|
+
const client = createMockSupabaseClient({
|
|
158
|
+
auth: { getSession: mock(async () => ({ data: { session: null } })) }
|
|
159
|
+
});
|
|
160
|
+
const auth = createSupabaseAuthProvider(client);
|
|
161
|
+
const result = await auth.onError!(new Error('401 Unauthorized'));
|
|
162
|
+
expect(result.logout).toBe(true);
|
|
163
|
+
expect(result.redirectTo).toBe('/login');
|
|
164
|
+
expect(client.auth.signOut).toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('onError swallows 401 if token actually exists (race condition guard)', async () => {
|
|
168
|
+
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
169
|
+
const client = createMockSupabaseClient();
|
|
170
|
+
const auth = createSupabaseAuthProvider(client);
|
|
171
|
+
const result = await auth.onError!(new Error('401 Unauthorized'));
|
|
172
|
+
expect(result.logout).toBeUndefined();
|
|
173
|
+
expect(client.auth.signOut).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
// ─── LiveProvider Tests ──────────────────────────────────────────
|
|
179
|
+
describe('Supabase LiveProvider', () => {
|
|
180
|
+
test('subscribe builds channel and binds events', async () => {
|
|
181
|
+
const { createSupabaseLiveProvider } = await import('./live-provider');
|
|
182
|
+
const client = createMockSupabaseClient();
|
|
183
|
+
const live = createSupabaseLiveProvider(client);
|
|
184
|
+
|
|
185
|
+
let callbackTriggered = false;
|
|
186
|
+
const unsub = live.subscribe({
|
|
187
|
+
resource: 'posts',
|
|
188
|
+
callback: (event) => { callbackTriggered = true; }
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(client.channel).toHaveBeenCalledWith('live-posts');
|
|
192
|
+
const channelMock = (client.channel as ReturnType<typeof mock>).mock.results[0].value;
|
|
193
|
+
expect(channelMock.on).toHaveBeenCalled();
|
|
194
|
+
expect(channelMock.subscribe).toHaveBeenCalled();
|
|
195
|
+
|
|
196
|
+
unsub();
|
|
197
|
+
expect(channelMock.unsubscribe).toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('publish triggers broadcast send', async () => {
|
|
201
|
+
const { createSupabaseLiveProvider } = await import('./live-provider');
|
|
202
|
+
const client = createMockSupabaseClient();
|
|
203
|
+
const live = createSupabaseLiveProvider(client);
|
|
204
|
+
|
|
205
|
+
live.publish?.({ type: 'INSERT', resource: 'posts', payload: { a: 1 } });
|
|
206
|
+
|
|
207
|
+
const channelMock = (client.channel as ReturnType<typeof mock>).mock.results[0].value;
|
|
208
|
+
expect(channelMock.send).toHaveBeenCalled();
|
|
209
|
+
const sendArgs = channelMock.send.mock.calls[0][0];
|
|
210
|
+
expect(sendArgs.type).toBe('broadcast');
|
|
211
|
+
expect(sendArgs.event).toBe('live-event');
|
|
212
|
+
expect(sendArgs.payload.type).toBe('INSERT');
|
|
213
|
+
});
|
|
214
|
+
});
|