@quatrain/cloudwrapper-supabase 1.1.22 → 1.1.24

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/LICENSE.md ADDED
@@ -0,0 +1,15 @@
1
+ # LICENSE UPDATE NOTICE
2
+
3
+ As of 01/01/2026, Quatrain Core is licensed under the **GNU Affero General Public License v3.0 (AGPL v3)**.
4
+ Previous versions remain under the MIT License.
5
+
6
+ ## Why AGPL?
7
+
8
+ We believe in open collaboration for the development ecosystem. The AGPL ensures that any modification or deployment of this BaaS stack, including over a network, benefits the entire community.
9
+
10
+ ## Commercial Services & Enterprise Usage
11
+
12
+ We provide official deployment services, technical training, and certification for Quatrain Core.
13
+ For organizations requiring a non-copyleft license (commercial license) or custom proprietary integrations, please contact the copyright holder: **Quatrain Technologies**.
14
+
15
+ Copyright © 2024-2026 Quatrain Technologies. All Rights Reserved.
package/README.md CHANGED
@@ -1,7 +1,10 @@
1
- # @quatrain/cloudwrapper-supabase
1
+ # Supabase Cloud Wrapper Package
2
2
 
3
- This package provides a Cloud Wrapper adapter for Supabase Edge Functions. It allows you to invoke serverless functions in a consistent way across different BaaS providers.
4
- This package provides a Cloud Wrapper for Supabase, enabling real-time database and storage triggers. It handles the WebSocket connection to Supabase's Realtime service, providing robust connection monitoring and configurable automatic reconnection logic.
3
+ ## Introduction
4
+
5
+ This package provides a Cloud Wrapper for Supabase, enabling real-time database
6
+ and storage triggers. It handles the WebSocket connection to Supabase's Realtime
7
+ service, providing robust connection monitoring and configurable automatic reconnection logic.
5
8
 
6
9
  ## Features
7
10
 
@@ -16,10 +19,10 @@ This package provides a Cloud Wrapper for Supabase, enabling real-time database
16
19
 
17
20
  ```bash
18
21
  # With npm
19
- npm install @quatrain/cloudwrapper-supabase @supabase/supabase-js ws
22
+ npm install @quatrain/cloudwrapper-supabase
20
23
 
21
24
  # With yarn
22
- yarn add @quatrain/cloudwrapper-supabase @supabase/supabase-js ws
25
+ yarn add @quatrain/cloudwrapper-supabase
23
26
  ```
24
27
 
25
28
  ## Usage
@@ -35,9 +38,9 @@ const wrapper = new SupabaseCloudWrapper({
35
38
  url: process.env.SUPABASE_URL,
36
39
  key: process.env.SUPABASE_KEY,
37
40
  // Optional: Determines behavior on disconnection.
38
- // - true (default): Exits the process. Ideal for containerized environments
39
- // (e.g., Kubernetes) that will automatically restart the service.
40
- // - false: Attempts to reconnect internally.
41
+ // - `true` (default): Exits the process. Ideal for containerized
42
+ // environments (e.g., Kubernetes) that will automatically restart the service.
43
+ // - `false`: Attempts to reconnect internally.
41
44
  exitOnDisconnect: true,
42
45
  })
43
46
 
@@ -52,3 +55,16 @@ wrapper.databaseTrigger({
52
55
  },
53
56
  })
54
57
  ```
58
+
59
+ ### Callback Payload
60
+
61
+ The `script` callback for both `databaseTrigger` and `storageTrigger` receives
62
+ an object with the following properties:
63
+
64
+ - `after`: The new state of the record after the event. For `DELETE` events, this will be an empty object.
65
+ - `before`: The state of the record before the event. For `INSERT` events, this will be an empty object.
66
+ - `context`: An object containing additional metadata about the event, such as
67
+ the schema, table, and commit timestamp provided by Supabase's Realtime service.
68
+
69
+ For `storageTrigger`, the `before` and `after` properties are transformed into a
70
+ `FileType` object, providing a standardized format for file metadata.
@@ -1,6 +1,6 @@
1
1
  /// <reference types="node" />
2
2
  import { AbstractCloudWrapper, DatabaseTriggerType, StorageTriggerType } from '@quatrain/cloudwrapper';
3
- import { SupabaseClient, RealtimeChannel } from '@supabase/supabase-js';
3
+ import { SupabaseClient } from '@supabase/supabase-js';
4
4
  import { FileType } from '@quatrain/storage';
5
5
  export type SupabaseParams = {
6
6
  useEmulator?: boolean;
@@ -8,15 +8,6 @@ export type SupabaseParams = {
8
8
  key?: string;
9
9
  exitOnDisconnect?: boolean;
10
10
  };
11
- export type Channel = {
12
- name: string;
13
- channel: RealtimeChannel | undefined;
14
- state: any;
15
- };
16
- export type Channels = {
17
- database: Channel[];
18
- storage: Channel[];
19
- };
20
11
  export declare const eventMap: {
21
12
  create: string;
22
13
  update: string;
@@ -24,11 +15,9 @@ export declare const eventMap: {
24
15
  };
25
16
  export declare class SupabaseCloudWrapper extends AbstractCloudWrapper {
26
17
  protected _supabaseClient: SupabaseClient | undefined;
27
- protected _realtimeClient: RealtimeChannel | undefined;
28
18
  protected _isInitialized: boolean;
29
19
  protected _heartbeatOkReceived: boolean;
30
20
  protected _connectionTimeout: NodeJS.Timeout | undefined;
31
- protected _channels: Channels;
32
21
  constructor(params: SupabaseParams);
33
22
  databaseTrigger(trigger: DatabaseTriggerType): void | {
34
23
  event: any;
@@ -38,17 +38,16 @@ class SupabaseCloudWrapper extends cloudwrapper_1.AbstractCloudWrapper {
38
38
  super(params);
39
39
  this._isInitialized = false;
40
40
  this._heartbeatOkReceived = false;
41
- this._channels = { database: [], storage: [] };
42
41
  this._initialize();
43
42
  }
44
43
  databaseTrigger(trigger) {
45
44
  var _a;
46
45
  this._initialize();
47
46
  if (!(this._supabaseClient instanceof supabase_js_1.SupabaseClient)) {
48
- throw new Error(`Realtime channel is not enabled`);
47
+ throw new TypeError(`Supabase client is not enabled`);
49
48
  }
50
49
  if (typeof trigger.script !== 'function') {
51
- throw new Error(`Passed script value is not a function`);
50
+ throw new TypeError(`Passed script value is not a function`);
52
51
  }
53
52
  if (Array.isArray(trigger.event)) {
54
53
  const params = trigger.event.forEach((event) => {
@@ -64,7 +63,7 @@ class SupabaseCloudWrapper extends cloudwrapper_1.AbstractCloudWrapper {
64
63
  table: trigger.model,
65
64
  };
66
65
  cloudwrapper_1.CloudWrapper.info(`Set up DB trigger ${trigger.name} for ${trigger.event} event on table ${schema}.${params.table}`);
67
- const channel = (_a = this._supabaseClient) === null || _a === void 0 ? void 0 : _a.channel(trigger.name).on('postgres_changes', params, (_b) => __awaiter(this, void 0, void 0, function* () {
66
+ (_a = this._supabaseClient) === null || _a === void 0 ? void 0 : _a.channel(trigger.name).on('postgres_changes', params, (_b) => __awaiter(this, void 0, void 0, function* () {
68
67
  var { old: before, new: after } = _b, context = __rest(_b, ["old", "new"]);
69
68
  cloudwrapper_1.CloudWrapper.info(`Triggering function on event ${trigger.name}`);
70
69
  try {
@@ -75,11 +74,6 @@ class SupabaseCloudWrapper extends cloudwrapper_1.AbstractCloudWrapper {
75
74
  console.log(err);
76
75
  }
77
76
  })).subscribe();
78
- this._channels.database.push({
79
- name: trigger.name,
80
- channel,
81
- state: channel.state,
82
- });
83
77
  return params;
84
78
  }
85
79
  catch (err) {
@@ -106,7 +100,7 @@ class SupabaseCloudWrapper extends cloudwrapper_1.AbstractCloudWrapper {
106
100
  table: 'objects',
107
101
  };
108
102
  cloudwrapper_1.CloudWrapper.info(`Set up Storage trigger ${trigger.name} for ${trigger.event} event`);
109
- const channel = (_a = this._supabaseClient) === null || _a === void 0 ? void 0 : _a.channel(trigger.name).on('postgres_changes', params, (_b) => __awaiter(this, void 0, void 0, function* () {
103
+ (_a = this._supabaseClient) === null || _a === void 0 ? void 0 : _a.channel(trigger.name).on('postgres_changes', params, (_b) => __awaiter(this, void 0, void 0, function* () {
110
104
  var { old: before, new: after } = _b, context = __rest(_b, ["old", "new"]);
111
105
  cloudwrapper_1.CloudWrapper.info(`Triggering storage function on event ${trigger.name}`);
112
106
  try {
@@ -122,11 +116,6 @@ class SupabaseCloudWrapper extends cloudwrapper_1.AbstractCloudWrapper {
122
116
  cloudwrapper_1.CloudWrapper.error(err.message);
123
117
  }
124
118
  })).subscribe();
125
- this._channels.storage.push({
126
- name: trigger.name,
127
- channel,
128
- state: channel === null || channel === void 0 ? void 0 : channel.state,
129
- });
130
119
  return params;
131
120
  }
132
121
  catch (err) {
@@ -156,19 +145,18 @@ class SupabaseCloudWrapper extends cloudwrapper_1.AbstractCloudWrapper {
156
145
  }
157
146
  break;
158
147
  case 'timeout':
159
- cloudwrapper_1.CloudWrapper.warn(`⚠️ Supabase Realtime connection timed out`);
160
- break;
148
+ case 'error':
161
149
  case 'disconnected':
162
150
  // Reconnect only if exitOnDisconnect is explicitly set to false.
163
151
  if (this._params.exitOnDisconnect === false) {
164
- cloudwrapper_1.CloudWrapper.warn(`❌ Supabase connection lost. Attempting to reconnect...`);
152
+ cloudwrapper_1.CloudWrapper.warn(`❌ Supabase connection lost (status: ${status}). Attempting to reconnect...`);
165
153
  this._isInitialized = false;
166
154
  this._heartbeatOkReceived = false;
167
155
  this._initialize();
168
156
  }
169
157
  else {
170
158
  // Default behavior: exit to allow for a clean restart by the orchestrator.
171
- cloudwrapper_1.CloudWrapper.error(`❌ Supabase connection lost. Exiting to allow for a clean restart.`);
159
+ cloudwrapper_1.CloudWrapper.error(`❌ Supabase connection lost (status: ${status}). Exiting to allow for a clean restart.`);
172
160
  process.exit(1);
173
161
  }
174
162
  break;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,393 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ const SupabaseCloudWrapper_1 = require("./SupabaseCloudWrapper");
13
+ const backend_1 = require("@quatrain/backend");
14
+ const supabase_js_1 = require("@supabase/supabase-js");
15
+ // Mock dependencies
16
+ jest.mock('@supabase/supabase-js');
17
+ jest.mock('ws');
18
+ describe('SupabaseCloudWrapper', () => {
19
+ let wrapper;
20
+ let mockChannel;
21
+ let mockSubscribe;
22
+ let mockOn;
23
+ let mockSupabaseClient;
24
+ let processExitSpy;
25
+ let heartbeatCallback;
26
+ beforeEach(() => {
27
+ jest.clearAllMocks();
28
+ jest.clearAllTimers();
29
+ jest.useFakeTimers();
30
+ // Spy on process.exit to prevent actual exits
31
+ processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
32
+ throw new Error('process.exit called');
33
+ });
34
+ // Create mock subscription chain
35
+ mockSubscribe = jest.fn().mockReturnValue(undefined);
36
+ mockOn = jest.fn().mockReturnValue({ subscribe: mockSubscribe });
37
+ mockChannel = jest.fn().mockReturnValue({ on: mockOn });
38
+ // Create mock Supabase client
39
+ mockSupabaseClient = {
40
+ channel: mockChannel,
41
+ };
42
+ supabase_js_1.createClient.mockImplementation((url, key, options) => {
43
+ var _a;
44
+ heartbeatCallback = (_a = options === null || options === void 0 ? void 0 : options.realtime) === null || _a === void 0 ? void 0 : _a.heartbeatCallback;
45
+ const client = new supabase_js_1.SupabaseClient(url, key, options);
46
+ Object.assign(client, { channel: mockChannel });
47
+ return client;
48
+ });
49
+ });
50
+ afterEach(() => {
51
+ jest.useRealTimers();
52
+ processExitSpy.mockRestore();
53
+ });
54
+ describe('eventMap', () => {
55
+ it('should map BackendAction to Supabase events', () => {
56
+ expect(SupabaseCloudWrapper_1.eventMap[backend_1.BackendAction.CREATE]).toBe('INSERT');
57
+ expect(SupabaseCloudWrapper_1.eventMap[backend_1.BackendAction.UPDATE]).toBe('UPDATE');
58
+ expect(SupabaseCloudWrapper_1.eventMap[backend_1.BackendAction.DELETE]).toBe('DELETE');
59
+ });
60
+ });
61
+ describe('Constructor and Initialization', () => {
62
+ it('should create Supabase client with correct params', () => {
63
+ wrapper = new SupabaseCloudWrapper_1.SupabaseCloudWrapper({
64
+ url: 'https://test.supabase.co',
65
+ key: 'test-key-123',
66
+ });
67
+ expect(supabase_js_1.createClient).toHaveBeenCalledWith('https://test.supabase.co', 'test-key-123', expect.objectContaining({
68
+ realtime: expect.objectContaining({
69
+ transport: expect.anything(),
70
+ heartbeatIntervalMs: 5000,
71
+ heartbeatCallback: expect.any(Function),
72
+ }),
73
+ }));
74
+ });
75
+ it('should set initialization flag', () => {
76
+ wrapper = new SupabaseCloudWrapper_1.SupabaseCloudWrapper({
77
+ url: 'https://test.supabase.co',
78
+ key: 'test-key',
79
+ });
80
+ expect(wrapper._isInitialized).toBe(true);
81
+ });
82
+ it('should initialize heartbeat monitoring', () => {
83
+ wrapper = new SupabaseCloudWrapper_1.SupabaseCloudWrapper({
84
+ url: 'https://test.supabase.co',
85
+ key: 'test-key',
86
+ });
87
+ expect(heartbeatCallback).toBeDefined();
88
+ expect(typeof heartbeatCallback).toBe('function');
89
+ });
90
+ it('should set connection timeout', () => {
91
+ wrapper = new SupabaseCloudWrapper_1.SupabaseCloudWrapper({
92
+ url: 'https://test.supabase.co',
93
+ key: 'test-key',
94
+ });
95
+ expect(wrapper._connectionTimeout).toBeDefined();
96
+ });
97
+ });
98
+ describe('Heartbeat Monitoring', () => {
99
+ beforeEach(() => {
100
+ wrapper = new SupabaseCloudWrapper_1.SupabaseCloudWrapper({
101
+ url: 'https://test.supabase.co',
102
+ key: 'test-key',
103
+ });
104
+ });
105
+ it('should handle "ok" heartbeat status', () => {
106
+ expect(wrapper._heartbeatOkReceived).toBe(false);
107
+ heartbeatCallback('ok');
108
+ expect(wrapper._heartbeatOkReceived).toBe(true);
109
+ });
110
+ it('should clear timeout on successful heartbeat', () => {
111
+ const initialTimeout = wrapper._connectionTimeout;
112
+ heartbeatCallback('ok');
113
+ expect(wrapper._connectionTimeout).toBe(initialTimeout);
114
+ });
115
+ it('should handle "timeout" status', () => {
116
+ // Should not throw or exit
117
+ expect(() => heartbeatCallback('timeout')).not.toThrow();
118
+ });
119
+ it('should reconnect on "disconnected" when exitOnDisconnect is false', () => {
120
+ wrapper = new SupabaseCloudWrapper_1.SupabaseCloudWrapper({
121
+ url: 'https://test.supabase.co',
122
+ key: 'test-key',
123
+ exitOnDisconnect: false,
124
+ });
125
+ const createClientCallsBefore = supabase_js_1.createClient.mock.calls
126
+ .length;
127
+ heartbeatCallback = supabase_js_1.createClient.mock.calls[createClientCallsBefore - 1][2].realtime.heartbeatCallback;
128
+ heartbeatCallback('disconnected');
129
+ // Should attempt reconnection (call createClient again)
130
+ expect(supabase_js_1.createClient.mock.calls.length).toBeGreaterThan(createClientCallsBefore);
131
+ });
132
+ it('should exit on "disconnected" by default', () => {
133
+ expect(() => heartbeatCallback('disconnected')).toThrow('process.exit called');
134
+ expect(processExitSpy).toHaveBeenCalledWith(1);
135
+ });
136
+ it('should exit on "disconnected" when exitOnDisconnect is true', () => {
137
+ wrapper = new SupabaseCloudWrapper_1.SupabaseCloudWrapper({
138
+ url: 'https://test.supabase.co',
139
+ key: 'test-key',
140
+ exitOnDisconnect: true,
141
+ });
142
+ heartbeatCallback = supabase_js_1.createClient.mock.calls[1][2]
143
+ .realtime.heartbeatCallback;
144
+ expect(() => heartbeatCallback('disconnected')).toThrow('process.exit called');
145
+ expect(processExitSpy).toHaveBeenCalledWith(1);
146
+ });
147
+ });
148
+ describe('Connection Timeout', () => {
149
+ it('should exit if heartbeat not received within 30 seconds', () => {
150
+ wrapper = new SupabaseCloudWrapper_1.SupabaseCloudWrapper({
151
+ url: 'https://test.supabase.co',
152
+ key: 'test-key',
153
+ });
154
+ expect(() => jest.advanceTimersByTime(30000)).toThrow('process.exit called');
155
+ expect(processExitSpy).toHaveBeenCalledWith(1);
156
+ });
157
+ it('should not exit if heartbeat received before timeout', () => {
158
+ wrapper = new SupabaseCloudWrapper_1.SupabaseCloudWrapper({
159
+ url: 'https://test.supabase.co',
160
+ key: 'test-key',
161
+ });
162
+ heartbeatCallback('ok');
163
+ expect(() => jest.advanceTimersByTime(30000)).not.toThrow();
164
+ expect(processExitSpy).not.toHaveBeenCalled();
165
+ });
166
+ });
167
+ describe('databaseTrigger()', () => {
168
+ beforeEach(() => {
169
+ wrapper = new SupabaseCloudWrapper_1.SupabaseCloudWrapper({
170
+ url: 'https://test.supabase.co',
171
+ key: 'test-key',
172
+ });
173
+ heartbeatCallback('ok'); // Simulate successful connection
174
+ });
175
+ it('should set up single event trigger', () => {
176
+ const trigger = {
177
+ name: 'test-trigger',
178
+ event: backend_1.BackendAction.CREATE,
179
+ script: jest.fn(),
180
+ model: 'users',
181
+ path: '/users/{id}',
182
+ schema: 'public',
183
+ };
184
+ wrapper.databaseTrigger(trigger);
185
+ expect(mockChannel).toHaveBeenCalledWith('test-trigger');
186
+ expect(mockOn).toHaveBeenCalledWith('postgres_changes', {
187
+ event: 'INSERT',
188
+ schema: 'public',
189
+ table: 'users',
190
+ }, expect.any(Function));
191
+ expect(mockSubscribe).toHaveBeenCalled();
192
+ });
193
+ it('should use default schema if not provided', () => {
194
+ const trigger = {
195
+ name: 'test-trigger',
196
+ event: backend_1.BackendAction.UPDATE,
197
+ script: jest.fn(),
198
+ model: 'posts',
199
+ path: '/posts/{id}',
200
+ };
201
+ wrapper.databaseTrigger(trigger);
202
+ expect(mockOn).toHaveBeenCalledWith('postgres_changes', expect.objectContaining({
203
+ schema: 'public',
204
+ }), expect.any(Function));
205
+ });
206
+ it('should handle multiple events', () => {
207
+ const trigger = {
208
+ name: 'multi-trigger',
209
+ event: [backend_1.BackendAction.CREATE, backend_1.BackendAction.UPDATE],
210
+ script: jest.fn(),
211
+ model: 'comments',
212
+ path: '/comments/{id}',
213
+ };
214
+ wrapper.databaseTrigger(trigger);
215
+ expect(mockChannel).toHaveBeenCalledWith('multi-trigger-create');
216
+ expect(mockChannel).toHaveBeenCalledWith('multi-trigger-update');
217
+ expect(mockSubscribe).toHaveBeenCalledTimes(2);
218
+ });
219
+ it('should throw TypeError if script is not a function', () => {
220
+ const trigger = {
221
+ name: 'bad-trigger',
222
+ event: backend_1.BackendAction.CREATE,
223
+ script: 'not-a-function',
224
+ model: 'users',
225
+ path: '/users/{id}',
226
+ };
227
+ expect(() => wrapper.databaseTrigger(trigger)).toThrow(TypeError);
228
+ expect(() => wrapper.databaseTrigger(trigger)).toThrow('Passed script value is not a function');
229
+ });
230
+ it('should execute script with payload when event fires', () => __awaiter(void 0, void 0, void 0, function* () {
231
+ const scriptMock = jest.fn().mockResolvedValue(undefined);
232
+ const trigger = {
233
+ name: 'test-trigger',
234
+ event: backend_1.BackendAction.CREATE,
235
+ script: scriptMock,
236
+ model: 'users',
237
+ path: '/users/{id}',
238
+ };
239
+ wrapper.databaseTrigger(trigger);
240
+ // Get the callback passed to .on()
241
+ const callback = mockOn.mock.calls[0][2];
242
+ const payload = {
243
+ old: { id: 1, name: 'old' },
244
+ new: { id: 1, name: 'new' },
245
+ extra: 'context',
246
+ };
247
+ yield callback(payload);
248
+ expect(scriptMock).toHaveBeenCalledWith({
249
+ before: payload.old,
250
+ after: payload.new,
251
+ context: { extra: 'context' },
252
+ });
253
+ }));
254
+ });
255
+ describe('storageTrigger()', () => {
256
+ beforeEach(() => {
257
+ wrapper = new SupabaseCloudWrapper_1.SupabaseCloudWrapper({
258
+ url: 'https://test.supabase.co',
259
+ key: 'test-key',
260
+ });
261
+ heartbeatCallback('ok');
262
+ });
263
+ it('should set up single event storage trigger', () => {
264
+ const trigger = {
265
+ name: 'storage-trigger',
266
+ event: backend_1.BackendAction.CREATE,
267
+ script: jest.fn(),
268
+ };
269
+ wrapper.storageTrigger(trigger);
270
+ expect(mockChannel).toHaveBeenCalledWith('storage-trigger');
271
+ expect(mockOn).toHaveBeenCalledWith('postgres_changes', {
272
+ event: 'INSERT',
273
+ schema: 'storage',
274
+ table: 'objects',
275
+ }, expect.any(Function));
276
+ expect(mockSubscribe).toHaveBeenCalled();
277
+ });
278
+ it('should handle multiple events', () => {
279
+ const trigger = {
280
+ name: 'multi-storage',
281
+ event: [backend_1.BackendAction.CREATE, backend_1.BackendAction.DELETE],
282
+ script: jest.fn(),
283
+ };
284
+ wrapper.storageTrigger(trigger);
285
+ expect(mockChannel).toHaveBeenCalledWith('multi-storage-create');
286
+ expect(mockChannel).toHaveBeenCalledWith('multi-storage-delete');
287
+ expect(mockSubscribe).toHaveBeenCalledTimes(2);
288
+ });
289
+ it('should throw TypeError if script is not a function', () => {
290
+ const trigger = {
291
+ name: 'bad-storage',
292
+ event: backend_1.BackendAction.CREATE,
293
+ script: 123,
294
+ };
295
+ expect(() => wrapper.storageTrigger(trigger)).toThrow(TypeError);
296
+ });
297
+ it('should transform payload and execute script', () => __awaiter(void 0, void 0, void 0, function* () {
298
+ const scriptMock = jest.fn().mockResolvedValue(undefined);
299
+ const trigger = {
300
+ name: 'storage-trigger',
301
+ event: backend_1.BackendAction.CREATE,
302
+ script: scriptMock,
303
+ };
304
+ wrapper.storageTrigger(trigger);
305
+ const callback = mockOn.mock.calls[0][2];
306
+ const payload = {
307
+ old: {},
308
+ new: {
309
+ bucket_id: 'uploads',
310
+ name: 'test.jpg',
311
+ metadata: { size: 1024, mimetype: 'image/jpeg' },
312
+ },
313
+ context: 'test-context',
314
+ };
315
+ yield callback(payload);
316
+ expect(scriptMock).toHaveBeenCalledWith({
317
+ before: undefined,
318
+ after: {
319
+ host: 'supabase',
320
+ bucket: 'uploads',
321
+ ref: 'test.jpg',
322
+ contentType: 'image/jpeg',
323
+ size: 1024,
324
+ },
325
+ context: { context: 'test-context' },
326
+ });
327
+ }));
328
+ });
329
+ describe('_payload2File()', () => {
330
+ beforeEach(() => {
331
+ wrapper = new SupabaseCloudWrapper_1.SupabaseCloudWrapper({
332
+ url: 'https://test.supabase.co',
333
+ key: 'test-key',
334
+ });
335
+ });
336
+ it('should convert valid payload to FileType', () => {
337
+ const payload = {
338
+ bucket_id: 'avatars',
339
+ name: 'profile.png',
340
+ metadata: {
341
+ size: 2048,
342
+ mimetype: 'image/png',
343
+ },
344
+ };
345
+ const result = wrapper._payload2File(payload);
346
+ expect(result).toEqual({
347
+ host: 'supabase',
348
+ bucket: 'avatars',
349
+ ref: 'profile.png',
350
+ contentType: 'image/png',
351
+ size: 2048,
352
+ });
353
+ });
354
+ it('should return undefined for payload without metadata', () => {
355
+ const payload = {
356
+ bucket_id: 'uploads',
357
+ name: 'file.txt',
358
+ };
359
+ const result = wrapper._payload2File(payload);
360
+ expect(result).toBeUndefined();
361
+ });
362
+ it('should return undefined for empty payload', () => {
363
+ const payload = {};
364
+ const result = wrapper._payload2File(payload);
365
+ expect(result).toBeUndefined();
366
+ });
367
+ it('should handle missing size or mimetype', () => {
368
+ const payload = {
369
+ bucket_id: 'docs',
370
+ name: 'document.pdf',
371
+ metadata: {},
372
+ };
373
+ const result = wrapper._payload2File(payload);
374
+ expect(result).toEqual({
375
+ host: 'supabase',
376
+ bucket: 'docs',
377
+ ref: 'document.pdf',
378
+ contentType: undefined,
379
+ size: undefined,
380
+ });
381
+ });
382
+ });
383
+ describe('Module Exports', () => {
384
+ it('should export SupabaseCloudWrapper', () => {
385
+ expect(SupabaseCloudWrapper_1.SupabaseCloudWrapper).toBeDefined();
386
+ expect(typeof SupabaseCloudWrapper_1.SupabaseCloudWrapper).toBe('function');
387
+ });
388
+ it('should export eventMap', () => {
389
+ expect(SupabaseCloudWrapper_1.eventMap).toBeDefined();
390
+ expect(typeof SupabaseCloudWrapper_1.eventMap).toBe('object');
391
+ });
392
+ });
393
+ });
package/package.json CHANGED
@@ -1,52 +1,51 @@
1
1
  {
2
- "name": "@quatrain/cloudwrapper-supabase",
3
- "version": "1.1.22",
4
- "license": "MIT",
5
- "description": "Cloud Wrapper adapter for Supabase",
6
- "repository": {
7
- "type": "git",
8
- "url": "https://github.com/quatrain/Core.git",
9
- "directory": "packages/cloudwrapper-supabase"
10
- },
11
- "main": "lib/index.js",
12
- "types": "lib/index.d.ts",
13
- "files": [
14
- "src/",
15
- "lib/",
16
- "README.md"
17
- ],
18
- "author": "Quatrain Développement SAS <developers@quatrain.com>",
19
- "peerDependencies": {
20
- "@quatrain/core": "^1.1.22"
21
- },
22
- "dependencies": {
23
- "@quatrain/backend": "^1.1.22",
24
- "@quatrain/cloudwrapper": "^1.1.14",
25
- "@supabase/supabase-js": "^2.82.0",
26
- "ws": "^8.18.3"
27
- },
28
- "devDependencies": {
29
- "@tsconfig/recommended": "^1.0.1",
30
- "@types/jest": "^27.0.3",
31
- "@types/node": "^22.10.1",
32
- "@types/ws": "^8.18.1",
33
- "jest": "^30.1.2",
34
- "jest-node-exports-resolver": "^1.1.6",
35
- "jest-serial-runner": "^1.2.2",
36
- "trace-unhandled": "^2.0.1",
37
- "ts-jest": "^27.1.2",
38
- "ts-node": "^10.9.1",
39
- "typescript": "^5.1.5"
40
- },
41
- "scripts": {
42
- "test-ci": "jest --runInBand",
43
- "build": "tsc",
44
- "wbuild": "tsc --watch",
45
- "bump-to": "yarn version",
46
- "hash": "node ../../bin/hashFolder.js",
47
- "hash:persist": "yarn hash > .hash_latest.txt",
48
- "hash:compare": "yarn hash > .hash_newest.txt && cmp -s .hash_latest.txt .hash_newest.txt",
49
- "publish": "yarn hash:compare || yarn publish:process",
50
- "publish:process": "yarn version patch && yarn build && yarn npm publish --access public && yarn hash:persist"
51
- }
52
- }
2
+ "name": "@quatrain/cloudwrapper-supabase",
3
+ "version": "1.1.24",
4
+ "license": "AGPL-3.0-only",
5
+ "description": "Cloud Wrapper adapter for Supabase",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/quatrain/Core.git",
9
+ "directory": "packages/cloudwrapper-supabase"
10
+ },
11
+ "main": "lib/index.js",
12
+ "types": "lib/index.d.ts",
13
+ "bun": "src/index.ts",
14
+ "files": [
15
+ "LICENSE.md",
16
+ "src/",
17
+ "lib/",
18
+ "README.md"
19
+ ],
20
+ "author": "Quatrain Développement SAS <developers@quatrain.com>",
21
+ "peerDependencies": {
22
+ "@quatrain/core": "^1.1.42"
23
+ },
24
+ "dependencies": {
25
+ "@quatrain/backend": "^1.1.26",
26
+ "@quatrain/cloudwrapper": "^1.1.15",
27
+ "@quatrain/core": "^1.1.42",
28
+ "@quatrain/storage": "^1.1.23",
29
+ "@supabase/supabase-js": "^2.87.3",
30
+ "ws": "^8.18.3"
31
+ },
32
+ "devDependencies": {
33
+ "@tsconfig/recommended": "^1.0.1",
34
+ "@types/jest": "^30.0.0",
35
+ "@types/node": "^22.10.1",
36
+ "@types/ws": "^8.18.1",
37
+ "jest": "^30.2.0",
38
+ "jest-node-exports-resolver": "^1.1.6",
39
+ "jest-serial-runner": "^1.2.2",
40
+ "trace-unhandled": "^2.0.1",
41
+ "ts-jest": "^29.4.1",
42
+ "ts-node": "^10.9.1",
43
+ "typescript": "^5.2.2"
44
+ },
45
+ "scripts": {
46
+ "test-ci": "jest --runInBand",
47
+ "build": "tsc",
48
+ "wbuild": "tsc --watch",
49
+ "bump-to": "yarn version"
50
+ }
51
+ }
@@ -0,0 +1,498 @@
1
+ import { SupabaseCloudWrapper, eventMap } from './SupabaseCloudWrapper'
2
+ import { BackendAction } from '@quatrain/backend'
3
+ import { DatabaseTriggerType, StorageTriggerType } from '@quatrain/cloudwrapper'
4
+ import { createClient, SupabaseClient } from '@supabase/supabase-js'
5
+
6
+ // Mock dependencies
7
+ jest.mock('@supabase/supabase-js')
8
+ jest.mock('ws')
9
+
10
+ describe('SupabaseCloudWrapper', () => {
11
+ let wrapper: SupabaseCloudWrapper
12
+ let mockChannel: any
13
+ let mockSubscribe: jest.Mock
14
+ let mockOn: jest.Mock
15
+ let mockSupabaseClient: any
16
+ let processExitSpy: jest.SpyInstance
17
+ let heartbeatCallback: any
18
+
19
+ beforeEach(() => {
20
+ jest.clearAllMocks()
21
+ jest.clearAllTimers()
22
+ jest.useFakeTimers()
23
+
24
+ // Spy on process.exit to prevent actual exits
25
+ processExitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
26
+ throw new Error('process.exit called')
27
+ })
28
+
29
+ // Create mock subscription chain
30
+ mockSubscribe = jest.fn().mockReturnValue(undefined)
31
+ mockOn = jest.fn().mockReturnValue({ subscribe: mockSubscribe })
32
+ mockChannel = jest.fn().mockReturnValue({ on: mockOn })
33
+
34
+ // Create mock Supabase client
35
+ mockSupabaseClient = {
36
+ channel: mockChannel,
37
+ }
38
+
39
+ // Mock createClient to capture heartbeat callback
40
+ ;(createClient as jest.Mock).mockImplementation((url, key, options) => {
41
+ heartbeatCallback = options?.realtime?.heartbeatCallback
42
+ const client = new SupabaseClient(url, key, options)
43
+ Object.assign(client, { channel: mockChannel })
44
+ return client
45
+ })
46
+ })
47
+
48
+ afterEach(() => {
49
+ jest.useRealTimers()
50
+ processExitSpy.mockRestore()
51
+ })
52
+
53
+ describe('eventMap', () => {
54
+ it('should map BackendAction to Supabase events', () => {
55
+ expect(eventMap[BackendAction.CREATE]).toBe('INSERT')
56
+ expect(eventMap[BackendAction.UPDATE]).toBe('UPDATE')
57
+ expect(eventMap[BackendAction.DELETE]).toBe('DELETE')
58
+ })
59
+ })
60
+
61
+ describe('Constructor and Initialization', () => {
62
+ it('should create Supabase client with correct params', () => {
63
+ wrapper = new SupabaseCloudWrapper({
64
+ url: 'https://test.supabase.co',
65
+ key: 'test-key-123',
66
+ })
67
+
68
+ expect(createClient).toHaveBeenCalledWith(
69
+ 'https://test.supabase.co',
70
+ 'test-key-123',
71
+ expect.objectContaining({
72
+ realtime: expect.objectContaining({
73
+ transport: expect.anything(),
74
+ heartbeatIntervalMs: 5000,
75
+ heartbeatCallback: expect.any(Function),
76
+ }),
77
+ })
78
+ )
79
+ })
80
+
81
+ it('should set initialization flag', () => {
82
+ wrapper = new SupabaseCloudWrapper({
83
+ url: 'https://test.supabase.co',
84
+ key: 'test-key',
85
+ })
86
+
87
+ expect((wrapper as any)._isInitialized).toBe(true)
88
+ })
89
+
90
+ it('should initialize heartbeat monitoring', () => {
91
+ wrapper = new SupabaseCloudWrapper({
92
+ url: 'https://test.supabase.co',
93
+ key: 'test-key',
94
+ })
95
+
96
+ expect(heartbeatCallback).toBeDefined()
97
+ expect(typeof heartbeatCallback).toBe('function')
98
+ })
99
+
100
+ it('should set connection timeout', () => {
101
+ wrapper = new SupabaseCloudWrapper({
102
+ url: 'https://test.supabase.co',
103
+ key: 'test-key',
104
+ })
105
+
106
+ expect((wrapper as any)._connectionTimeout).toBeDefined()
107
+ })
108
+ })
109
+
110
+ describe('Heartbeat Monitoring', () => {
111
+ beforeEach(() => {
112
+ wrapper = new SupabaseCloudWrapper({
113
+ url: 'https://test.supabase.co',
114
+ key: 'test-key',
115
+ })
116
+ })
117
+
118
+ it('should handle "ok" heartbeat status', () => {
119
+ expect((wrapper as any)._heartbeatOkReceived).toBe(false)
120
+
121
+ heartbeatCallback('ok')
122
+
123
+ expect((wrapper as any)._heartbeatOkReceived).toBe(true)
124
+ })
125
+
126
+ it('should clear timeout on successful heartbeat', () => {
127
+ const initialTimeout = (wrapper as any)._connectionTimeout
128
+
129
+ heartbeatCallback('ok')
130
+
131
+ expect((wrapper as any)._connectionTimeout).toBe(initialTimeout)
132
+ })
133
+
134
+ it('should handle "timeout" status', () => {
135
+ // Should not throw or exit
136
+ expect(() => heartbeatCallback('timeout')).not.toThrow()
137
+ })
138
+
139
+ it('should reconnect on "disconnected" when exitOnDisconnect is false', () => {
140
+ wrapper = new SupabaseCloudWrapper({
141
+ url: 'https://test.supabase.co',
142
+ key: 'test-key',
143
+ exitOnDisconnect: false,
144
+ })
145
+
146
+ const createClientCallsBefore = (createClient as jest.Mock).mock.calls
147
+ .length
148
+
149
+ heartbeatCallback = (createClient as jest.Mock).mock.calls[
150
+ createClientCallsBefore - 1
151
+ ][2].realtime.heartbeatCallback
152
+
153
+ heartbeatCallback('disconnected')
154
+
155
+ // Should attempt reconnection (call createClient again)
156
+ expect((createClient as jest.Mock).mock.calls.length).toBeGreaterThan(
157
+ createClientCallsBefore
158
+ )
159
+ })
160
+
161
+ it('should exit on "disconnected" by default', () => {
162
+ expect(() => heartbeatCallback('disconnected')).toThrow(
163
+ 'process.exit called'
164
+ )
165
+ expect(processExitSpy).toHaveBeenCalledWith(1)
166
+ })
167
+
168
+ it('should exit on "disconnected" when exitOnDisconnect is true', () => {
169
+ wrapper = new SupabaseCloudWrapper({
170
+ url: 'https://test.supabase.co',
171
+ key: 'test-key',
172
+ exitOnDisconnect: true,
173
+ })
174
+
175
+ heartbeatCallback = (createClient as jest.Mock).mock.calls[1][2]
176
+ .realtime.heartbeatCallback
177
+
178
+ expect(() => heartbeatCallback('disconnected')).toThrow(
179
+ 'process.exit called'
180
+ )
181
+ expect(processExitSpy).toHaveBeenCalledWith(1)
182
+ })
183
+ })
184
+
185
+ describe('Connection Timeout', () => {
186
+ it('should exit if heartbeat not received within 30 seconds', () => {
187
+ wrapper = new SupabaseCloudWrapper({
188
+ url: 'https://test.supabase.co',
189
+ key: 'test-key',
190
+ })
191
+
192
+ expect(() => jest.advanceTimersByTime(30000)).toThrow(
193
+ 'process.exit called'
194
+ )
195
+ expect(processExitSpy).toHaveBeenCalledWith(1)
196
+ })
197
+
198
+ it('should not exit if heartbeat received before timeout', () => {
199
+ wrapper = new SupabaseCloudWrapper({
200
+ url: 'https://test.supabase.co',
201
+ key: 'test-key',
202
+ })
203
+
204
+ heartbeatCallback('ok')
205
+
206
+ expect(() => jest.advanceTimersByTime(30000)).not.toThrow()
207
+ expect(processExitSpy).not.toHaveBeenCalled()
208
+ })
209
+ })
210
+
211
+ describe('databaseTrigger()', () => {
212
+ beforeEach(() => {
213
+ wrapper = new SupabaseCloudWrapper({
214
+ url: 'https://test.supabase.co',
215
+ key: 'test-key',
216
+ })
217
+ heartbeatCallback('ok') // Simulate successful connection
218
+ })
219
+
220
+ it('should set up single event trigger', () => {
221
+ const trigger: DatabaseTriggerType = {
222
+ name: 'test-trigger',
223
+ event: BackendAction.CREATE,
224
+ script: jest.fn(),
225
+ model: 'users',
226
+ path: '/users/{id}',
227
+ schema: 'public',
228
+ }
229
+
230
+ wrapper.databaseTrigger(trigger)
231
+
232
+ expect(mockChannel).toHaveBeenCalledWith('test-trigger')
233
+ expect(mockOn).toHaveBeenCalledWith(
234
+ 'postgres_changes',
235
+ {
236
+ event: 'INSERT',
237
+ schema: 'public',
238
+ table: 'users',
239
+ },
240
+ expect.any(Function)
241
+ )
242
+ expect(mockSubscribe).toHaveBeenCalled()
243
+ })
244
+
245
+ it('should use default schema if not provided', () => {
246
+ const trigger: DatabaseTriggerType = {
247
+ name: 'test-trigger',
248
+ event: BackendAction.UPDATE,
249
+ script: jest.fn(),
250
+ model: 'posts',
251
+ path: '/posts/{id}',
252
+ }
253
+
254
+ wrapper.databaseTrigger(trigger)
255
+
256
+ expect(mockOn).toHaveBeenCalledWith(
257
+ 'postgres_changes',
258
+ expect.objectContaining({
259
+ schema: 'public',
260
+ }),
261
+ expect.any(Function)
262
+ )
263
+ })
264
+
265
+ it('should handle multiple events', () => {
266
+ const trigger: DatabaseTriggerType = {
267
+ name: 'multi-trigger',
268
+ event: [BackendAction.CREATE, BackendAction.UPDATE],
269
+ script: jest.fn(),
270
+ model: 'comments',
271
+ path: '/comments/{id}',
272
+ }
273
+
274
+ wrapper.databaseTrigger(trigger)
275
+
276
+ expect(mockChannel).toHaveBeenCalledWith('multi-trigger-create')
277
+ expect(mockChannel).toHaveBeenCalledWith('multi-trigger-update')
278
+ expect(mockSubscribe).toHaveBeenCalledTimes(2)
279
+ })
280
+
281
+ it('should throw TypeError if script is not a function', () => {
282
+ const trigger: any = {
283
+ name: 'bad-trigger',
284
+ event: BackendAction.CREATE,
285
+ script: 'not-a-function',
286
+ model: 'users',
287
+ path: '/users/{id}',
288
+ }
289
+
290
+ expect(() => wrapper.databaseTrigger(trigger)).toThrow(TypeError)
291
+ expect(() => wrapper.databaseTrigger(trigger)).toThrow(
292
+ 'Passed script value is not a function'
293
+ )
294
+ })
295
+
296
+ it('should execute script with payload when event fires', async () => {
297
+ const scriptMock = jest.fn().mockResolvedValue(undefined)
298
+ const trigger: DatabaseTriggerType = {
299
+ name: 'test-trigger',
300
+ event: BackendAction.CREATE,
301
+ script: scriptMock,
302
+ model: 'users',
303
+ path: '/users/{id}',
304
+ }
305
+
306
+ wrapper.databaseTrigger(trigger)
307
+
308
+ // Get the callback passed to .on()
309
+ const callback = mockOn.mock.calls[0][2]
310
+
311
+ const payload = {
312
+ old: { id: 1, name: 'old' },
313
+ new: { id: 1, name: 'new' },
314
+ extra: 'context',
315
+ }
316
+
317
+ await callback(payload)
318
+
319
+ expect(scriptMock).toHaveBeenCalledWith({
320
+ before: payload.old,
321
+ after: payload.new,
322
+ context: { extra: 'context' },
323
+ })
324
+ })
325
+ })
326
+
327
+ describe('storageTrigger()', () => {
328
+ beforeEach(() => {
329
+ wrapper = new SupabaseCloudWrapper({
330
+ url: 'https://test.supabase.co',
331
+ key: 'test-key',
332
+ })
333
+ heartbeatCallback('ok')
334
+ })
335
+
336
+ it('should set up single event storage trigger', () => {
337
+ const trigger: StorageTriggerType = {
338
+ name: 'storage-trigger',
339
+ event: BackendAction.CREATE,
340
+ script: jest.fn(),
341
+ }
342
+
343
+ wrapper.storageTrigger(trigger)
344
+
345
+ expect(mockChannel).toHaveBeenCalledWith('storage-trigger')
346
+ expect(mockOn).toHaveBeenCalledWith(
347
+ 'postgres_changes',
348
+ {
349
+ event: 'INSERT',
350
+ schema: 'storage',
351
+ table: 'objects',
352
+ },
353
+ expect.any(Function)
354
+ )
355
+ expect(mockSubscribe).toHaveBeenCalled()
356
+ })
357
+
358
+ it('should handle multiple events', () => {
359
+ const trigger: StorageTriggerType = {
360
+ name: 'multi-storage',
361
+ event: [BackendAction.CREATE, BackendAction.DELETE],
362
+ script: jest.fn(),
363
+ }
364
+
365
+ wrapper.storageTrigger(trigger)
366
+
367
+ expect(mockChannel).toHaveBeenCalledWith('multi-storage-create')
368
+ expect(mockChannel).toHaveBeenCalledWith('multi-storage-delete')
369
+ expect(mockSubscribe).toHaveBeenCalledTimes(2)
370
+ })
371
+
372
+ it('should throw TypeError if script is not a function', () => {
373
+ const trigger: any = {
374
+ name: 'bad-storage',
375
+ event: BackendAction.CREATE,
376
+ script: 123,
377
+ }
378
+
379
+ expect(() => wrapper.storageTrigger(trigger)).toThrow(TypeError)
380
+ })
381
+
382
+ it('should transform payload and execute script', async () => {
383
+ const scriptMock = jest.fn().mockResolvedValue(undefined)
384
+ const trigger: StorageTriggerType = {
385
+ name: 'storage-trigger',
386
+ event: BackendAction.CREATE,
387
+ script: scriptMock,
388
+ }
389
+
390
+ wrapper.storageTrigger(trigger)
391
+
392
+ const callback = mockOn.mock.calls[0][2]
393
+
394
+ const payload = {
395
+ old: {},
396
+ new: {
397
+ bucket_id: 'uploads',
398
+ name: 'test.jpg',
399
+ metadata: { size: 1024, mimetype: 'image/jpeg' },
400
+ },
401
+ context: 'test-context',
402
+ }
403
+
404
+ await callback(payload)
405
+
406
+ expect(scriptMock).toHaveBeenCalledWith({
407
+ before: undefined,
408
+ after: {
409
+ host: 'supabase',
410
+ bucket: 'uploads',
411
+ ref: 'test.jpg',
412
+ contentType: 'image/jpeg',
413
+ size: 1024,
414
+ },
415
+ context: { context: 'test-context' },
416
+ })
417
+ })
418
+ })
419
+
420
+ describe('_payload2File()', () => {
421
+ beforeEach(() => {
422
+ wrapper = new SupabaseCloudWrapper({
423
+ url: 'https://test.supabase.co',
424
+ key: 'test-key',
425
+ })
426
+ })
427
+
428
+ it('should convert valid payload to FileType', () => {
429
+ const payload = {
430
+ bucket_id: 'avatars',
431
+ name: 'profile.png',
432
+ metadata: {
433
+ size: 2048,
434
+ mimetype: 'image/png',
435
+ },
436
+ }
437
+
438
+ const result = (wrapper as any)._payload2File(payload)
439
+
440
+ expect(result).toEqual({
441
+ host: 'supabase',
442
+ bucket: 'avatars',
443
+ ref: 'profile.png',
444
+ contentType: 'image/png',
445
+ size: 2048,
446
+ })
447
+ })
448
+
449
+ it('should return undefined for payload without metadata', () => {
450
+ const payload = {
451
+ bucket_id: 'uploads',
452
+ name: 'file.txt',
453
+ }
454
+
455
+ const result = (wrapper as any)._payload2File(payload)
456
+
457
+ expect(result).toBeUndefined()
458
+ })
459
+
460
+ it('should return undefined for empty payload', () => {
461
+ const payload = {}
462
+
463
+ const result = (wrapper as any)._payload2File(payload)
464
+
465
+ expect(result).toBeUndefined()
466
+ })
467
+
468
+ it('should handle missing size or mimetype', () => {
469
+ const payload = {
470
+ bucket_id: 'docs',
471
+ name: 'document.pdf',
472
+ metadata: {},
473
+ }
474
+
475
+ const result = (wrapper as any)._payload2File(payload)
476
+
477
+ expect(result).toEqual({
478
+ host: 'supabase',
479
+ bucket: 'docs',
480
+ ref: 'document.pdf',
481
+ contentType: undefined,
482
+ size: undefined,
483
+ })
484
+ })
485
+ })
486
+
487
+ describe('Module Exports', () => {
488
+ it('should export SupabaseCloudWrapper', () => {
489
+ expect(SupabaseCloudWrapper).toBeDefined()
490
+ expect(typeof SupabaseCloudWrapper).toBe('function')
491
+ })
492
+
493
+ it('should export eventMap', () => {
494
+ expect(eventMap).toBeDefined()
495
+ expect(typeof eventMap).toBe('object')
496
+ })
497
+ })
498
+ })
@@ -6,15 +6,12 @@ import {
6
6
  StorageEventPayloadType,
7
7
  } from '@quatrain/cloudwrapper'
8
8
  import { BackendAction } from '@quatrain/backend'
9
- import {
10
- createClient,
11
- SupabaseClient,
12
- RealtimeChannel,
13
- } from '@supabase/supabase-js'
14
- import { HeartbeatStatus } from '@supabase/realtime-js/dist/module/RealtimeClient'
9
+ import { createClient, SupabaseClient } from '@supabase/supabase-js'
15
10
  import ws from 'ws'
16
11
  import { FileType } from '@quatrain/storage'
17
12
 
13
+ type HeartbeatStatus = 'sent' | 'ok' | 'error' | 'timeout' | 'disconnected'
14
+
18
15
  export type SupabaseParams = {
19
16
  useEmulator?: boolean
20
17
  url?: string
@@ -22,17 +19,6 @@ export type SupabaseParams = {
22
19
  exitOnDisconnect?: boolean
23
20
  }
24
21
 
25
- export type Channel = {
26
- name: string
27
- channel: RealtimeChannel | undefined
28
- state: any
29
- }
30
-
31
- export type Channels = {
32
- database: Channel[]
33
- storage: Channel[]
34
- }
35
-
36
22
  export const eventMap = {
37
23
  [BackendAction.CREATE]: 'INSERT',
38
24
  [BackendAction.UPDATE]: 'UPDATE',
@@ -41,11 +27,9 @@ export const eventMap = {
41
27
 
42
28
  export class SupabaseCloudWrapper extends AbstractCloudWrapper {
43
29
  protected _supabaseClient: SupabaseClient | undefined
44
- protected _realtimeClient: RealtimeChannel | undefined
45
30
  protected _isInitialized = false
46
31
  protected _heartbeatOkReceived = false
47
32
  protected _connectionTimeout: NodeJS.Timeout | undefined
48
- protected _channels: Channels = { database: [], storage: [] }
49
33
 
50
34
  constructor(params: SupabaseParams) {
51
35
  super(params)
@@ -56,11 +40,11 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
56
40
  this._initialize()
57
41
 
58
42
  if (!(this._supabaseClient instanceof SupabaseClient)) {
59
- throw new Error(`Realtime channel is not enabled`)
43
+ throw new TypeError(`Supabase client is not enabled`)
60
44
  }
61
45
 
62
46
  if (typeof trigger.script !== 'function') {
63
- throw new Error(`Passed script value is not a function`)
47
+ throw new TypeError(`Passed script value is not a function`)
64
48
  }
65
49
 
66
50
  if (Array.isArray(trigger.event)) {
@@ -81,10 +65,11 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
81
65
  schema,
82
66
  table: trigger.model,
83
67
  }
68
+
84
69
  CloudWrapper.info(
85
70
  `Set up DB trigger ${trigger.name} for ${trigger.event} event on table ${schema}.${params.table}`
86
71
  )
87
- const channel = this._supabaseClient
72
+ this._supabaseClient
88
73
  ?.channel(trigger.name)
89
74
  .on(
90
75
  'postgres_changes',
@@ -102,11 +87,6 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
102
87
  }
103
88
  )
104
89
  .subscribe()
105
- this._channels.database.push({
106
- name: trigger.name,
107
- channel,
108
- state: channel.state,
109
- })
110
90
 
111
91
  return params
112
92
  } catch (err) {
@@ -143,7 +123,7 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
143
123
  CloudWrapper.info(
144
124
  `Set up Storage trigger ${trigger.name} for ${trigger.event} event`
145
125
  )
146
- const channel = this._supabaseClient
126
+ this._supabaseClient
147
127
  ?.channel(trigger.name)
148
128
  .on(
149
129
  'postgres_changes',
@@ -166,11 +146,7 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
166
146
  }
167
147
  )
168
148
  .subscribe()
169
- this._channels.storage.push({
170
- name: trigger.name,
171
- channel,
172
- state: channel?.state,
173
- })
149
+
174
150
  return params
175
151
  } catch (err) {
176
152
  console.log(err)
@@ -207,15 +183,12 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
207
183
  }
208
184
  break
209
185
  case 'timeout':
210
- CloudWrapper.warn(
211
- `⚠️ Supabase Realtime connection timed out`
212
- )
213
- break
186
+ case 'error':
214
187
  case 'disconnected':
215
188
  // Reconnect only if exitOnDisconnect is explicitly set to false.
216
189
  if (this._params.exitOnDisconnect === false) {
217
190
  CloudWrapper.warn(
218
- `❌ Supabase connection lost. Attempting to reconnect...`
191
+ `❌ Supabase connection lost (status: ${status}). Attempting to reconnect...`
219
192
  )
220
193
  this._isInitialized = false
221
194
  this._heartbeatOkReceived = false
@@ -223,7 +196,7 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
223
196
  } else {
224
197
  // Default behavior: exit to allow for a clean restart by the orchestrator.
225
198
  CloudWrapper.error(
226
- `❌ Supabase connection lost. Exiting to allow for a clean restart.`
199
+ `❌ Supabase connection lost (status: ${status}). Exiting to allow for a clean restart.`
227
200
  )
228
201
  process.exit(1)
229
202
  }