@svadmin/supabase 0.13.9 → 0.15.0

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.
@@ -1,207 +1,141 @@
1
- import type { CustomParams, CustomResult, DataProvider } from '@svadmin/core';
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 type { SupabaseClient } from '@supabase/supabase-js';
4
- // Static import — dynamic import() gets dropped by Vite/Rollup in static SPA builds
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
- * Creates a supabase data provider using the official @refinedev/supabase package
14
- * with full support for custom RPC calls, Edge Functions, and schema targeting.
15
- *
16
- * @param client SupabaseClient instance
17
- * @param options Optional provider configuration
18
- * @returns A fully compatible svadmin DataProvider with custom RPC support
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
- const refineProvider = refineDataProvider(client as any);
33
- const adapter = createRefineAdapter(refineProvider);
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
- // Provide first-class custom implementation for Supabase
36
- adapter.custom = async <TData = unknown, TVariables = unknown>({
37
- url,
38
- method = 'get',
39
- payload,
40
- query,
41
- headers,
42
- filters,
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
- // 1. Edge Function invocation
50
- const isEdgeFunction =
51
- meta?.function === true ||
52
- meta?.edgeFunction === true ||
53
- url.startsWith('functions/') ||
54
- url.startsWith('functions:');
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
- if (isEdgeFunction) {
57
- const functionName = url.replace(/^functions[/:]/, '');
58
- const { data, error } = await client.functions.invoke(functionName, {
59
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
- body: payload as any,
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
- if (error) {
65
- throw new Error(
66
- `[svadmin/supabase] Edge Function "${functionName}" failed: ${error.message}`
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
- return { data: data as TData };
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
- // 2. PostgreSQL Stored Procedure / RPC
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
- const rpcArgs = (payload ?? query ?? {}) as Record<string, unknown>;
89
- const rpcOptions: { head?: boolean; count?: 'exact' | 'planned' | 'estimated'; get?: boolean } = {};
90
- if (meta?.head) rpcOptions.head = Boolean(meta.head);
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
- const dbQuery = targetClient.from(url);
108
-
109
- if (method === 'get') {
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
- if (method === 'post') {
140
- const { data, error } = await dbQuery
141
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
142
- .insert(payload as any)
143
- .select((meta?.select as string) ?? '*');
144
- if (error) {
145
- throw new Error(`[svadmin/supabase] Insert into "${url}" failed: ${error.message}`);
146
- }
147
- return { data: (Array.isArray(data) ? data[0] : data) as TData };
148
- }
149
-
150
- if (method === 'put' || method === 'patch') {
151
- let updateQuery = dbQuery
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 === 'delete') {
169
- let deleteQuery = dbQuery.delete();
170
- if (query) {
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
- // 4. Fallback to standard HTTP fetch for absolute URLs
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: fetchHeaders,
193
- body: payload ? JSON.stringify(payload) : undefined,
194
- });
195
-
196
- if (!response.ok) {
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 { createSupabaseLiveProvider } from './live-provider';
12
- export { createSupabaseAuditHandler } from './audit-handler';
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
- SupaCloudSdkTaskRecord,
28
- SupaCloudSdkTaskSubmitOptions,
29
- SupaCloudTaskReceipt,
30
- SupaCloudTaskSnapshot,
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';
@@ -1,90 +1,208 @@
1
- // Supabase LiveProvider — Real-time subscriptions via Supabase Realtime
2
- import type { SupabaseClient, RealtimeChannel } from '@supabase/supabase-js';
3
- import type { LiveProvider, LiveEvent } from '@svadmin/core';
4
-
5
- export function createSupabaseLiveProvider(client: SupabaseClient): LiveProvider {
6
- const subscribers = new Map<string, Set<(event: LiveEvent) => void>>();
7
- const channels = new Map<string, RealtimeChannel>();
8
- const broadcastChannels = new Map<string, RealtimeChannel>();
9
-
10
- function getOrCreateChannel(resource: string): RealtimeChannel {
11
- let channel = channels.get(resource);
12
- if (!channel) {
13
- channel = client.channel(`live-${resource}`)
14
- .on(
15
- 'postgres_changes',
16
- { event: '*', schema: 'public', table: resource },
17
- (payload) => {
18
- const event: LiveEvent = {
19
- type: payload.eventType as LiveEvent['type'],
20
- resource,
21
- payload: (payload.new ?? payload.old ?? {}) as Record<string, unknown>,
22
- };
23
- subscribers.get(resource)?.forEach(cb => cb(event));
24
- }
25
- )
26
- .on('broadcast', { event: 'live-event' }, ({ payload }) => {
27
- if (payload) {
28
- subscribers.get(resource)?.forEach(cb => cb(payload as LiveEvent));
29
- }
30
- })
31
- .subscribe();
32
- channels.set(resource, channel);
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
- return channel;
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 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);
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
- if (!subscribers.has(resource)) subscribers.set(resource, new Set());
57
- subscribers.get(resource)?.add(callback);
58
-
59
- getOrCreateChannel(resource);
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
- const cbs = subscribers.get(resource);
63
- if (cbs) {
64
- cbs.delete(callback);
65
- if (cbs.size === 0) {
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(event: LiveEvent) {
83
- const channel = getOrCreateBroadcastChannel(event.resource);
84
- channel.send({
85
- type: 'broadcast',
86
- event: 'live-event',
87
- payload: event,
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
  };