@svadmin/supabase 0.13.9 → 0.15.1
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 +7 -7
- package/src/audit-handler.ts +59 -10
- package/src/auth-contract.ts +169 -0
- package/src/auth-provider.ts +262 -123
- package/src/data-provider.ts +112 -178
- package/src/index.ts +16 -11
- package/src/live-provider.ts +193 -75
- package/src/permission-adapter.ts +28 -0
- package/src/rpc.ts +11 -29
- package/src/sdk-boundary.ts +79 -0
- package/src/supacloud.ts +220 -594
package/src/data-provider.ts
CHANGED
|
@@ -1,207 +1,141 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { definedOptions } from '@svadmin/core/options';
|
|
2
|
+
import { decodeCustomResult, rejectProviderResponse, type DataTransport } from '@svadmin/core/schema';
|
|
3
|
+
import type { DataProvider, Filter } from '@svadmin/core';
|
|
2
4
|
import { createRefineAdapter } from '@svadmin/refine-adapter';
|
|
3
|
-
import
|
|
4
|
-
// Static import
|
|
5
|
+
import { isObject, inputObject, inputRecord, optionalString, optionalBoolean, optionalCount, callSdk, sdkData, executeSupabaseRpc } from './sdk-boundary';
|
|
6
|
+
// Static import keeps the provider available in bundled SPA builds.
|
|
5
7
|
import { dataProvider as refineDataProvider } from '@refinedev/supabase';
|
|
6
8
|
|
|
7
9
|
export interface CreateSupabaseDataProviderOptions {
|
|
8
|
-
/** Default schema for table and RPC operations */
|
|
10
|
+
/** Default schema for table and RPC operations. */
|
|
9
11
|
schema?: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*/
|
|
20
|
-
export function createSupabaseDataProvider(
|
|
21
|
-
client: SupabaseClient,
|
|
22
|
-
options: CreateSupabaseDataProviderOptions = {}
|
|
23
|
-
): DataProvider {
|
|
24
|
-
if (typeof refineDataProvider !== 'function') {
|
|
25
|
-
throw new Error(
|
|
26
|
-
'[svadmin/supabase] Failed to resolve @refinedev/supabase data provider. ' +
|
|
27
|
-
'Ensure the package is installed correctly: bun add @refinedev/supabase'
|
|
28
|
-
);
|
|
29
|
-
}
|
|
14
|
+
function edgeBody(value: unknown): unknown {
|
|
15
|
+
if (value === undefined || typeof value === 'string') return value;
|
|
16
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob) return value;
|
|
17
|
+
if (value instanceof ArrayBuffer) return value;
|
|
18
|
+
if (typeof FormData !== 'undefined' && value instanceof FormData) return value;
|
|
19
|
+
return inputRecord(value, 'Supabase Edge Function body');
|
|
20
|
+
}
|
|
30
21
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
22
|
+
function applyEqualityQuery(builder: unknown, query: Record<string, unknown> | undefined): unknown {
|
|
23
|
+
let result = builder;
|
|
24
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
25
|
+
if (value !== undefined) result = callSdk(result, 'eq', [key, value]);
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
34
29
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
sorters,
|
|
44
|
-
meta,
|
|
45
|
-
}: CustomParams<TVariables>): Promise<CustomResult<TData>> => {
|
|
46
|
-
const targetSchema = (meta?.schema as string | undefined) ?? options.schema;
|
|
47
|
-
const targetClient = targetSchema ? client.schema(targetSchema) : client;
|
|
30
|
+
function validateFilters(filters: readonly Filter[] | undefined): void {
|
|
31
|
+
for (const filter of filters ?? []) {
|
|
32
|
+
if (!('field' in filter) || filter.operator !== 'eq') {
|
|
33
|
+
throw new TypeError('Supabase custom table requests support only equality filters');
|
|
34
|
+
}
|
|
35
|
+
if (filter.value === null || filter.value === undefined) throw new TypeError('Supabase equality filters require a non-null value');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
48
38
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
39
|
+
function applyFilters(builder: unknown, filters: readonly Filter[] | undefined): unknown {
|
|
40
|
+
let result = builder;
|
|
41
|
+
for (const filter of filters ?? []) {
|
|
42
|
+
if ('field' in filter) result = callSdk(result, 'eq', [filter.field, filter.value]);
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
55
46
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
headers: headers as Record<string, string>,
|
|
62
|
-
});
|
|
47
|
+
async function parseHttpResponse(response: Response): Promise<unknown> {
|
|
48
|
+
if (response.status === 204 || response.status === 205 || response.headers.get('content-length')?.trim() === '0') return undefined;
|
|
49
|
+
const body = await response.text();
|
|
50
|
+
return body.trim() ? JSON.parse(body) : undefined;
|
|
51
|
+
}
|
|
63
52
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Adapts a Supabase SDK instance without assuming its dynamic response types.
|
|
55
|
+
* Resource/command contracts provide the business schema above this transport.
|
|
56
|
+
*/
|
|
57
|
+
export function createSupabaseDataProvider(
|
|
58
|
+
client: unknown,
|
|
59
|
+
options: CreateSupabaseDataProviderOptions = {},
|
|
60
|
+
): DataProvider {
|
|
61
|
+
inputObject(client, 'Supabase client');
|
|
62
|
+
const defaultSchema = optionalString(options, 'schema');
|
|
63
|
+
if (typeof refineDataProvider !== 'function') throw new Error('[svadmin/supabase] Missing @refinedev/supabase data provider');
|
|
64
|
+
const adapter = createRefineAdapter(refineDataProvider(client));
|
|
65
|
+
|
|
66
|
+
const executeCustom: NonNullable<DataTransport['custom']> = async ({
|
|
67
|
+
url, method, payload, query, headers, filters, sorters, meta,
|
|
68
|
+
}) => {
|
|
69
|
+
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) throw new TypeError('Unsupported Supabase request method');
|
|
70
|
+
const targetSchema = optionalString(meta, 'schema') ?? defaultSchema;
|
|
71
|
+
const select = optionalString(meta, 'select') ?? '*';
|
|
72
|
+
const rpc = optionalBoolean(meta, 'rpc');
|
|
73
|
+
const edge = optionalBoolean(meta, 'function');
|
|
74
|
+
const edgeFunction = optionalBoolean(meta, 'edgeFunction');
|
|
75
|
+
const rpcOptions = definedOptions({
|
|
76
|
+
head: optionalBoolean(meta, 'head'), get: optionalBoolean(meta, 'get') ?? method === 'get', count: optionalCount(meta),
|
|
77
|
+
});
|
|
78
|
+
const write = method !== 'get';
|
|
69
79
|
|
|
70
|
-
|
|
80
|
+
if (edge || edgeFunction || url.startsWith('functions/') || url.startsWith('functions:')) {
|
|
81
|
+
const functionName = url.replace(/^functions[/:]/, '');
|
|
82
|
+
if (!functionName.trim()) throw new TypeError('Supabase function name cannot be empty');
|
|
83
|
+
const source = inputObject(client, 'Supabase client');
|
|
84
|
+
const body = edgeBody(payload);
|
|
85
|
+
if (method === 'get' && body !== undefined) throw new TypeError('A GET function request cannot have a body');
|
|
86
|
+
const invocation = callSdk(source['functions'], 'invoke', [functionName, definedOptions({
|
|
87
|
+
body,
|
|
88
|
+
headers,
|
|
89
|
+
method: method.toUpperCase(),
|
|
90
|
+
})]);
|
|
91
|
+
return { data: await sdkData(invocation, `Edge Function "${functionName}"`, write) };
|
|
71
92
|
}
|
|
72
93
|
|
|
73
|
-
|
|
74
|
-
const isExplicitRpc =
|
|
75
|
-
meta?.rpc === true ||
|
|
76
|
-
url.startsWith('rpc/') ||
|
|
77
|
-
url.startsWith('rpc:');
|
|
78
|
-
const isImplicitRpc =
|
|
79
|
-
!isExplicitRpc &&
|
|
80
|
-
meta?.rpc !== false &&
|
|
81
|
-
!url.startsWith('http://') &&
|
|
82
|
-
!url.startsWith('https://') &&
|
|
83
|
-
!url.includes('/') &&
|
|
84
|
-
(method === 'post' || meta?.rpc === true);
|
|
85
|
-
|
|
86
|
-
if (isExplicitRpc || (isImplicitRpc && meta?.rpc === true)) {
|
|
94
|
+
if (rpc === true || url.startsWith('rpc/') || url.startsWith('rpc:')) {
|
|
87
95
|
const functionName = url.replace(/^rpc[/:]/, '');
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
if (meta?.count) rpcOptions.count = meta.count as 'exact' | 'planned' | 'estimated';
|
|
92
|
-
if (meta?.get) rpcOptions.get = Boolean(meta.get);
|
|
93
|
-
|
|
94
|
-
const { data, error } = await targetClient.rpc(functionName, rpcArgs, rpcOptions);
|
|
95
|
-
|
|
96
|
-
if (error) {
|
|
97
|
-
throw new Error(
|
|
98
|
-
`[svadmin/supabase] RPC function "${functionName}" failed: ${error.message}`
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return { data: data as TData };
|
|
96
|
+
if (!functionName.trim()) throw new TypeError('Supabase RPC name cannot be empty');
|
|
97
|
+
const args = payload === undefined ? query ?? {} : payload;
|
|
98
|
+
return { data: await executeSupabaseRpc(client, functionName, args, definedOptions({ schema: targetSchema, ...rpcOptions })) };
|
|
103
99
|
}
|
|
104
100
|
|
|
105
|
-
// 3. Direct Table / View Query via PostgREST client
|
|
106
101
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
let selectQuery = dbQuery.select((meta?.select as string) ?? '*');
|
|
111
|
-
if (query) {
|
|
112
|
-
for (const [key, value] of Object.entries(query)) {
|
|
113
|
-
if (value !== undefined && value !== null) {
|
|
114
|
-
selectQuery = selectQuery.eq(key, value);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (filters && Array.isArray(filters)) {
|
|
119
|
-
for (const filter of filters) {
|
|
120
|
-
if ('field' in filter && 'value' in filter) {
|
|
121
|
-
selectQuery = selectQuery.eq(filter.field, filter.value);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
if (sorters && Array.isArray(sorters)) {
|
|
126
|
-
for (const sorter of sorters) {
|
|
127
|
-
selectQuery = selectQuery.order(sorter.field, {
|
|
128
|
-
ascending: sorter.order === 'asc',
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
const { data, error } = await selectQuery;
|
|
133
|
-
if (error) {
|
|
134
|
-
throw new Error(`[svadmin/supabase] Query "${url}" failed: ${error.message}`);
|
|
135
|
-
}
|
|
136
|
-
return { data: data as TData };
|
|
102
|
+
validateFilters(filters);
|
|
103
|
+
if (Object.values(query ?? {}).some(value => value === null)) {
|
|
104
|
+
throw new TypeError('Supabase equality queries require non-null values');
|
|
137
105
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
153
|
-
.update(payload as any);
|
|
154
|
-
if (query) {
|
|
155
|
-
for (const [key, value] of Object.entries(query)) {
|
|
156
|
-
if (value !== undefined && value !== null) {
|
|
157
|
-
updateQuery = updateQuery.eq(key, value);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
const { data, error } = await updateQuery.select((meta?.select as string) ?? '*');
|
|
162
|
-
if (error) {
|
|
163
|
-
throw new Error(`[svadmin/supabase] Update "${url}" failed: ${error.message}`);
|
|
164
|
-
}
|
|
165
|
-
return { data: (Array.isArray(data) ? data[0] : data) as TData };
|
|
106
|
+
const variables = method === 'post' || method === 'put' || method === 'patch'
|
|
107
|
+
? inputRecord(payload, 'Supabase table variables') : undefined;
|
|
108
|
+
const target = targetSchema ? callSdk(client, 'schema', [targetSchema]) : client;
|
|
109
|
+
const table = callSdk(target, 'from', [url]);
|
|
110
|
+
let builder: unknown;
|
|
111
|
+
if (method === 'get') builder = callSdk(table, 'select', [select]);
|
|
112
|
+
else if (method === 'post') builder = callSdk(table, 'insert', [variables]);
|
|
113
|
+
else if (method === 'put' || method === 'patch') builder = callSdk(table, 'update', [variables]);
|
|
114
|
+
else builder = callSdk(table, 'delete', []);
|
|
115
|
+
builder = applyFilters(applyEqualityQuery(builder, query), filters);
|
|
116
|
+
if (method === 'get') {
|
|
117
|
+
for (const sorter of sorters ?? []) builder = callSdk(builder, 'order', [sorter.field, { ascending: sorter.order === 'asc' }]);
|
|
118
|
+
} else {
|
|
119
|
+
builder = callSdk(builder, 'select', [select]);
|
|
166
120
|
}
|
|
167
|
-
|
|
168
|
-
if (method === '
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
for (const [key, value] of Object.entries(query)) {
|
|
172
|
-
if (value !== undefined && value !== null) {
|
|
173
|
-
deleteQuery = deleteQuery.eq(key, value);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
const { data, error } = await deleteQuery.select((meta?.select as string) ?? '*');
|
|
178
|
-
if (error) {
|
|
179
|
-
throw new Error(`[svadmin/supabase] Delete from "${url}" failed: ${error.message}`);
|
|
180
|
-
}
|
|
181
|
-
return { data: data as TData };
|
|
121
|
+
const data = await sdkData(builder, `Table request "${url}"`, write);
|
|
122
|
+
if (method === 'post' || method === 'put' || method === 'patch') {
|
|
123
|
+
if (!Array.isArray(data) || data.length !== 1 || !isObject(data[0])) return rejectProviderResponse(write);
|
|
124
|
+
return { data: data[0] };
|
|
182
125
|
}
|
|
126
|
+
if (!Array.isArray(data) || !data.every(isObject)) return rejectProviderResponse(write);
|
|
127
|
+
return { data };
|
|
183
128
|
}
|
|
184
129
|
|
|
185
|
-
|
|
186
|
-
const fetchHeaders: Record<string, string> = {
|
|
187
|
-
'Content-Type': 'application/json',
|
|
188
|
-
...(headers ?? {}),
|
|
189
|
-
};
|
|
190
|
-
const response = await fetch(url, {
|
|
130
|
+
const response = await fetch(url, definedOptions({
|
|
191
131
|
method: method.toUpperCase(),
|
|
192
|
-
headers:
|
|
193
|
-
body: payload ? JSON.stringify(payload)
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
throw new Error(
|
|
198
|
-
`[svadmin/supabase] Custom request to "${url}" failed with status ${response.status}`
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const data = await response.json();
|
|
203
|
-
return { data: data as TData };
|
|
132
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
133
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
134
|
+
}));
|
|
135
|
+
if (!response.ok) throw new Error(`[svadmin/supabase] Custom request failed with status ${response.status}`);
|
|
136
|
+
return { data: await parseHttpResponse(response) };
|
|
204
137
|
};
|
|
205
138
|
|
|
139
|
+
adapter.custom = async params => decodeCustomResult(await executeCustom(params), params.method !== 'get');
|
|
206
140
|
return adapter;
|
|
207
141
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,13 +3,23 @@
|
|
|
3
3
|
export { createSupabaseDataProvider } from './data-provider';
|
|
4
4
|
export type { CreateSupabaseDataProviderOptions } from './data-provider';
|
|
5
5
|
export { createSupabaseAuthProvider } from './auth-provider';
|
|
6
|
+
export {
|
|
7
|
+
createSupabasePermissionAccessControlProvider,
|
|
8
|
+
registerSupabasePermissionAccessControl,
|
|
9
|
+
} from './permission-adapter';
|
|
10
|
+
export type { SupabasePermissionSnapshot, SupabasePermissionAdapterOptions } from './permission-adapter';
|
|
6
11
|
export type {
|
|
7
12
|
SupabaseAuthProviderOptions,
|
|
13
|
+
SupabaseAuthClient,
|
|
8
14
|
SupabasePermissionResolver,
|
|
9
15
|
SupabasePermissionResolverContext,
|
|
10
16
|
} from './auth-provider';
|
|
11
|
-
export {
|
|
12
|
-
export {
|
|
17
|
+
export { SupabaseAuthError } from './auth-contract';
|
|
18
|
+
export type { SupabaseAuthUser } from './auth-contract';
|
|
19
|
+
export { createSupabaseLiveProvider, SupabaseLiveError } from './live-provider';
|
|
20
|
+
export type { SupabaseRealtimeChannel, SupabaseRealtimeClient, SupabaseLiveOptions } from './live-provider';
|
|
21
|
+
export { createSupabaseAuditHandler, SupabaseAuditError } from './audit-handler';
|
|
22
|
+
export type { SupabaseAuditClient, SupabaseAuditRow } from './audit-handler';
|
|
13
23
|
export { createSupabaseRpc } from './rpc';
|
|
14
24
|
export type {
|
|
15
25
|
SupabaseRpcClient,
|
|
@@ -21,17 +31,12 @@ export {
|
|
|
21
31
|
} from './supacloud';
|
|
22
32
|
export type {
|
|
23
33
|
SupaCloudTaskClient,
|
|
24
|
-
SupaCloudTaskLegacyClient,
|
|
25
|
-
SupaCloudTaskSdkClient,
|
|
26
34
|
SupaCloudTaskRecord,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
SupaCloudTaskSubscribeState,
|
|
35
|
+
SupaCloudTaskHandle,
|
|
36
|
+
SupaCloudTaskProvider,
|
|
37
|
+
SupaCloudTaskListParams,
|
|
38
|
+
SupaCloudTaskDlqParams,
|
|
32
39
|
SupaCloudTaskSubscribeOptions,
|
|
33
|
-
SupaCloudSdkTaskWaitOptions,
|
|
34
|
-
SupaCloudSdkTaskClient,
|
|
35
40
|
CreateSupaCloudTaskProviderOptions,
|
|
36
41
|
CreateSupaCloudTaskLiveProviderOptions,
|
|
37
42
|
} from './supacloud';
|
package/src/live-provider.ts
CHANGED
|
@@ -1,90 +1,208 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
1
|
+
import { Type, type Static, type TSchema } from '@sinclair/typebox';
|
|
2
|
+
import { checkExact, snapshotPlainData } from '@svadmin/core/schema';
|
|
3
|
+
import type { LiveEvent, LiveProvider } from '@svadmin/core';
|
|
4
|
+
|
|
5
|
+
export interface SupabaseRealtimeChannel {
|
|
6
|
+
on(type: 'postgres_changes', filter: { event: '*'; schema: 'public'; table: string }, callback: (payload: unknown) => void): unknown;
|
|
7
|
+
on(type: 'broadcast', filter: { event: 'live-event' }, callback: (payload: unknown) => void): unknown;
|
|
8
|
+
subscribe(callback: (status: unknown) => void): unknown;
|
|
9
|
+
send(message: { type: 'broadcast'; event: 'live-event'; payload: LiveEvent }): Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SupabaseRealtimeClient<C extends SupabaseRealtimeChannel> {
|
|
13
|
+
channel(name: string, options: { config: { broadcast: { ack: true } } }): C;
|
|
14
|
+
removeChannel(channel: C): Promise<unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type LiveErrorCode = 'INVALID_INPUT' | 'INVALID_EVENT' | 'SUBSCRIBE_FAILED' |
|
|
18
|
+
'UNSUBSCRIBE_FAILED' | 'PUBLISH_OUTCOME_UNKNOWN' | 'CALLBACK_FAILED';
|
|
19
|
+
|
|
20
|
+
export class SupabaseLiveError extends Error {
|
|
21
|
+
constructor(readonly code: LiveErrorCode) {
|
|
22
|
+
super(`Supabase realtime ${code.toLowerCase().replaceAll('_', ' ')}`);
|
|
23
|
+
this.name = 'SupabaseLiveError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SupabaseLiveOptions {
|
|
28
|
+
onError?: (error: SupabaseLiveError) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resourceSchema = Type.String({ minLength: 1, pattern: '^[A-Za-z0-9_]+$' });
|
|
32
|
+
const action = Type.Union([Type.Literal('INSERT'), Type.Literal('UPDATE'), Type.Literal('DELETE')]);
|
|
33
|
+
const record = Type.Record(Type.String(), Type.Unknown());
|
|
34
|
+
const eventSchema = Type.Object({
|
|
35
|
+
type: action, resource: resourceSchema, payload: record,
|
|
36
|
+
}, { additionalProperties: false });
|
|
37
|
+
const broadcastSchema = Type.Object({
|
|
38
|
+
type: Type.Literal('broadcast'), event: Type.Literal('live-event'), payload: eventSchema,
|
|
39
|
+
});
|
|
40
|
+
const postgresSchema = Type.Object({
|
|
41
|
+
schema: Type.Literal('public'), table: resourceSchema, commit_timestamp: Type.String(),
|
|
42
|
+
eventType: action, new: record, old: record,
|
|
43
|
+
errors: Type.Union([Type.Null(), Type.Array(Type.String())]),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function decode<S extends TSchema>(schema: S, value: unknown, code: LiveErrorCode): Static<S> {
|
|
47
|
+
try {
|
|
48
|
+
const candidate = snapshotPlainData(value);
|
|
49
|
+
if (checkExact(schema, candidate)) return candidate;
|
|
50
|
+
} catch {
|
|
51
|
+
// SDK callbacks and payload errors must not expose submitted data.
|
|
52
|
+
}
|
|
53
|
+
throw new SupabaseLiveError(code);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function postgresEvent(value: unknown, resource: string): LiveEvent {
|
|
57
|
+
const event = decode(postgresSchema, value, 'INVALID_EVENT');
|
|
58
|
+
if (event.table !== resource || event.errors?.length) throw new SupabaseLiveError('INVALID_EVENT');
|
|
59
|
+
const payload = event.eventType === 'DELETE' ? event.old : event.new;
|
|
60
|
+
if (Object.keys(payload).length === 0) throw new SupabaseLiveError('INVALID_EVENT');
|
|
61
|
+
return { type: event.eventType, resource, payload };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Subscription {
|
|
65
|
+
callback: (event: LiveEvent) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ChannelState<C> {
|
|
69
|
+
resource: string;
|
|
70
|
+
listeners: Set<Subscription>;
|
|
71
|
+
pending: number;
|
|
72
|
+
retired: boolean;
|
|
73
|
+
started: boolean;
|
|
74
|
+
channel?: C;
|
|
75
|
+
ready: Promise<C | undefined>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** All SDK data stays unknown until validated; one channel is owned per resource. */
|
|
79
|
+
export function createSupabaseLiveProvider<C extends SupabaseRealtimeChannel>(
|
|
80
|
+
client: SupabaseRealtimeClient<C>, options: SupabaseLiveOptions = {},
|
|
81
|
+
): LiveProvider {
|
|
82
|
+
const channels = new Map<string, ChannelState<C>>();
|
|
83
|
+
const closing = new Map<string, Promise<void>>();
|
|
84
|
+
const failedCleanup = new Set<string>();
|
|
85
|
+
const report = (code: LiveErrorCode): void => {
|
|
86
|
+
const error = new SupabaseLiveError(code);
|
|
87
|
+
if (!options.onError) { console.error(error); return; }
|
|
88
|
+
try {
|
|
89
|
+
void Promise.resolve(options.onError(error)).catch(() => {
|
|
90
|
+
console.error('Supabase realtime error handler failed');
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch { console.error('Supabase realtime error handler failed'); }
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function release(state: ChannelState<C>, force = false): void {
|
|
97
|
+
if (state.retired || (!force && (state.listeners.size > 0 || state.pending > 0))) return;
|
|
98
|
+
state.retired = true;
|
|
99
|
+
if (channels.get(state.resource) === state) channels.delete(state.resource);
|
|
100
|
+
const completion = state.ready.then(async () => {
|
|
101
|
+
if (!state.channel) return;
|
|
102
|
+
const receipt: unknown = await client.removeChannel(state.channel);
|
|
103
|
+
if (receipt !== 'ok') throw new SupabaseLiveError('UNSUBSCRIBE_FAILED');
|
|
104
|
+
}).catch(() => {
|
|
105
|
+
failedCleanup.add(state.resource);
|
|
106
|
+
report('UNSUBSCRIBE_FAILED');
|
|
107
|
+
});
|
|
108
|
+
closing.set(state.resource, completion);
|
|
109
|
+
void completion.then(() => {
|
|
110
|
+
if (closing.get(state.resource) === completion) closing.delete(state.resource);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function deliver(state: ChannelState<C>, value: unknown, kind: 'postgres' | 'broadcast'): void {
|
|
115
|
+
if (state.retired || state.listeners.size === 0) return;
|
|
116
|
+
let event: LiveEvent;
|
|
117
|
+
try {
|
|
118
|
+
event = kind === 'postgres' ? postgresEvent(value, state.resource)
|
|
119
|
+
: decode(broadcastSchema, value, 'INVALID_EVENT').payload;
|
|
120
|
+
if (event.resource !== state.resource) throw new SupabaseLiveError('INVALID_EVENT');
|
|
121
|
+
} catch {
|
|
122
|
+
report('INVALID_EVENT');
|
|
123
|
+
return;
|
|
33
124
|
}
|
|
34
|
-
|
|
125
|
+
// Each registration owns its lifetime and its copy, even with identical callbacks.
|
|
126
|
+
for (const subscription of [...state.listeners]) {
|
|
127
|
+
if (state.retired || !state.listeners.has(subscription)) continue;
|
|
128
|
+
const copy = decode(eventSchema, event, 'INVALID_EVENT');
|
|
129
|
+
try {
|
|
130
|
+
void Promise.resolve(subscription.callback(copy)).catch(() => report('CALLBACK_FAILED'));
|
|
131
|
+
}
|
|
132
|
+
catch { report('CALLBACK_FAILED'); }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getChannel(resource: string): ChannelState<C> {
|
|
137
|
+
const existing = channels.get(resource);
|
|
138
|
+
if (existing) return existing;
|
|
139
|
+
const predecessor = closing.get(resource);
|
|
140
|
+
const state: ChannelState<C> = {
|
|
141
|
+
resource, listeners: new Set(), pending: 0, retired: false, started: false,
|
|
142
|
+
ready: Promise.resolve(undefined),
|
|
143
|
+
};
|
|
144
|
+
channels.set(resource, state);
|
|
145
|
+
state.ready = Promise.resolve(predecessor).then(() => {
|
|
146
|
+
if (state.retired) return undefined;
|
|
147
|
+
// An unconfirmed removal may leave the old topic cached in the SDK.
|
|
148
|
+
if (failedCleanup.has(resource)) throw new SupabaseLiveError('SUBSCRIBE_FAILED');
|
|
149
|
+
const channel = client.channel(`live-${resource}`, { config: { broadcast: { ack: true } } });
|
|
150
|
+
state.channel = channel;
|
|
151
|
+
channel.on('postgres_changes', { event: '*', schema: 'public', table: resource },
|
|
152
|
+
value => deliver(state, value, 'postgres'));
|
|
153
|
+
channel.on('broadcast', { event: 'live-event' }, value => deliver(state, value, 'broadcast'));
|
|
154
|
+
return channel;
|
|
155
|
+
}).catch(() => {
|
|
156
|
+
report('SUBSCRIBE_FAILED');
|
|
157
|
+
release(state, true);
|
|
158
|
+
return undefined;
|
|
159
|
+
});
|
|
160
|
+
return state;
|
|
35
161
|
}
|
|
36
162
|
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
163
|
+
function start(state: ChannelState<C>, channel: C): void {
|
|
164
|
+
if (state.retired || state.started || state.listeners.size === 0) return;
|
|
165
|
+
state.started = true;
|
|
166
|
+
try {
|
|
167
|
+
channel.subscribe(status => {
|
|
168
|
+
if (state.retired || status === 'SUBSCRIBED') return;
|
|
169
|
+
report('SUBSCRIBE_FAILED');
|
|
170
|
+
// The SDK reconnects these two states. Closed or malformed states are terminal.
|
|
171
|
+
if (status !== 'CHANNEL_ERROR' && status !== 'TIMED_OUT') release(state, true);
|
|
172
|
+
});
|
|
173
|
+
} catch {
|
|
174
|
+
report('SUBSCRIBE_FAILED');
|
|
175
|
+
release(state, true);
|
|
50
176
|
}
|
|
51
|
-
return channel;
|
|
52
177
|
}
|
|
53
178
|
|
|
54
179
|
return {
|
|
55
180
|
subscribe({ resource, callback }) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
181
|
+
const name = decode(resourceSchema, resource, 'INVALID_INPUT');
|
|
182
|
+
const state = getChannel(name);
|
|
183
|
+
const subscription = { callback };
|
|
184
|
+
state.listeners.add(subscription);
|
|
185
|
+
void state.ready.then(channel => { if (channel) start(state, channel); });
|
|
186
|
+
let cancelled = false;
|
|
61
187
|
return () => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
subscribers.delete(resource);
|
|
67
|
-
const channel = channels.get(resource);
|
|
68
|
-
if (channel) {
|
|
69
|
-
channel.unsubscribe();
|
|
70
|
-
channels.delete(resource);
|
|
71
|
-
}
|
|
72
|
-
const bc = broadcastChannels.get(resource);
|
|
73
|
-
if (bc) {
|
|
74
|
-
bc.unsubscribe();
|
|
75
|
-
broadcastChannels.delete(resource);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
188
|
+
if (cancelled) return;
|
|
189
|
+
cancelled = true;
|
|
190
|
+
state.listeners.delete(subscription);
|
|
191
|
+
release(state);
|
|
79
192
|
};
|
|
80
193
|
},
|
|
81
194
|
|
|
82
|
-
publish(
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
195
|
+
publish(value) {
|
|
196
|
+
const event = decode(eventSchema, value, 'INVALID_INPUT');
|
|
197
|
+
const state = getChannel(event.resource);
|
|
198
|
+
state.pending++;
|
|
199
|
+
void state.ready.then(async channel => {
|
|
200
|
+
if (!channel || state.retired) throw new SupabaseLiveError('PUBLISH_OUTCOME_UNKNOWN');
|
|
201
|
+
const receipt: unknown = await channel.send({ type: 'broadcast', event: 'live-event', payload: event });
|
|
202
|
+
if (receipt !== 'ok') throw new SupabaseLiveError('PUBLISH_OUTCOME_UNKNOWN');
|
|
203
|
+
}).catch(() => report('PUBLISH_OUTCOME_UNKNOWN')).finally(() => {
|
|
204
|
+
state.pending--;
|
|
205
|
+
release(state);
|
|
88
206
|
});
|
|
89
207
|
},
|
|
90
208
|
};
|