@quatrain/cloudwrapper-supabase 1.1.22 → 1.1.23
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/README.md +24 -8
- package/lib/SupabaseCloudWrapper.d.ts +1 -12
- package/lib/SupabaseCloudWrapper.js +7 -19
- package/lib/SupabaseCloudWrapper.test.d.ts +1 -0
- package/lib/SupabaseCloudWrapper.test.js +393 -0
- package/package.json +9 -9
- package/src/SupabaseCloudWrapper.test.ts +498 -0
- package/src/SupabaseCloudWrapper.ts +10 -38
package/README.md
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Supabase Cloud Wrapper Package
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
22
|
+
npm install @quatrain/cloudwrapper-supabase
|
|
20
23
|
|
|
21
24
|
# With yarn
|
|
22
|
-
yarn add @quatrain/cloudwrapper-supabase
|
|
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
|
|
39
|
-
// (e.g., Kubernetes) that will automatically restart the service.
|
|
40
|
-
// - false
|
|
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
|
|
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
|
|
47
|
+
throw new TypeError(`Supabase client is not enabled`);
|
|
49
48
|
}
|
|
50
49
|
if (typeof trigger.script !== 'function') {
|
|
51
|
-
throw new
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quatrain/cloudwrapper-supabase",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"license": "
|
|
3
|
+
"version": "1.1.23",
|
|
4
|
+
"license": "AGPL-3.0-only",
|
|
5
5
|
"description": "Cloud Wrapper adapter for Supabase",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -17,26 +17,26 @@
|
|
|
17
17
|
],
|
|
18
18
|
"author": "Quatrain Développement SAS <developers@quatrain.com>",
|
|
19
19
|
"peerDependencies": {
|
|
20
|
-
"@quatrain/core": "^1.1.
|
|
20
|
+
"@quatrain/core": "^1.1.24"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@quatrain/backend": "^1.1.
|
|
23
|
+
"@quatrain/backend": "^1.1.23",
|
|
24
24
|
"@quatrain/cloudwrapper": "^1.1.14",
|
|
25
|
-
"@supabase/supabase-js": "^2.
|
|
25
|
+
"@supabase/supabase-js": "^2.87.3",
|
|
26
26
|
"ws": "^8.18.3"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@tsconfig/recommended": "^1.0.1",
|
|
30
|
-
"@types/jest": "^
|
|
30
|
+
"@types/jest": "^30.0.0",
|
|
31
31
|
"@types/node": "^22.10.1",
|
|
32
32
|
"@types/ws": "^8.18.1",
|
|
33
|
-
"jest": "^30.
|
|
33
|
+
"jest": "^30.2.0",
|
|
34
34
|
"jest-node-exports-resolver": "^1.1.6",
|
|
35
35
|
"jest-serial-runner": "^1.2.2",
|
|
36
36
|
"trace-unhandled": "^2.0.1",
|
|
37
|
-
"ts-jest": "^
|
|
37
|
+
"ts-jest": "^29.4.1",
|
|
38
38
|
"ts-node": "^10.9.1",
|
|
39
|
-
"typescript": "^5.
|
|
39
|
+
"typescript": "^5.2.2"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"test-ci": "jest --runInBand",
|
|
@@ -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,11 +6,7 @@ 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'
|
|
9
|
+
import { createClient, SupabaseClient } from '@supabase/supabase-js'
|
|
14
10
|
import { HeartbeatStatus } from '@supabase/realtime-js/dist/module/RealtimeClient'
|
|
15
11
|
import ws from 'ws'
|
|
16
12
|
import { FileType } from '@quatrain/storage'
|
|
@@ -22,17 +18,6 @@ export type SupabaseParams = {
|
|
|
22
18
|
exitOnDisconnect?: boolean
|
|
23
19
|
}
|
|
24
20
|
|
|
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
21
|
export const eventMap = {
|
|
37
22
|
[BackendAction.CREATE]: 'INSERT',
|
|
38
23
|
[BackendAction.UPDATE]: 'UPDATE',
|
|
@@ -41,11 +26,9 @@ export const eventMap = {
|
|
|
41
26
|
|
|
42
27
|
export class SupabaseCloudWrapper extends AbstractCloudWrapper {
|
|
43
28
|
protected _supabaseClient: SupabaseClient | undefined
|
|
44
|
-
protected _realtimeClient: RealtimeChannel | undefined
|
|
45
29
|
protected _isInitialized = false
|
|
46
30
|
protected _heartbeatOkReceived = false
|
|
47
31
|
protected _connectionTimeout: NodeJS.Timeout | undefined
|
|
48
|
-
protected _channels: Channels = { database: [], storage: [] }
|
|
49
32
|
|
|
50
33
|
constructor(params: SupabaseParams) {
|
|
51
34
|
super(params)
|
|
@@ -56,11 +39,11 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
|
|
|
56
39
|
this._initialize()
|
|
57
40
|
|
|
58
41
|
if (!(this._supabaseClient instanceof SupabaseClient)) {
|
|
59
|
-
throw new
|
|
42
|
+
throw new TypeError(`Supabase client is not enabled`)
|
|
60
43
|
}
|
|
61
44
|
|
|
62
45
|
if (typeof trigger.script !== 'function') {
|
|
63
|
-
throw new
|
|
46
|
+
throw new TypeError(`Passed script value is not a function`)
|
|
64
47
|
}
|
|
65
48
|
|
|
66
49
|
if (Array.isArray(trigger.event)) {
|
|
@@ -81,10 +64,11 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
|
|
|
81
64
|
schema,
|
|
82
65
|
table: trigger.model,
|
|
83
66
|
}
|
|
67
|
+
|
|
84
68
|
CloudWrapper.info(
|
|
85
69
|
`Set up DB trigger ${trigger.name} for ${trigger.event} event on table ${schema}.${params.table}`
|
|
86
70
|
)
|
|
87
|
-
|
|
71
|
+
this._supabaseClient
|
|
88
72
|
?.channel(trigger.name)
|
|
89
73
|
.on(
|
|
90
74
|
'postgres_changes',
|
|
@@ -102,11 +86,6 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
|
|
|
102
86
|
}
|
|
103
87
|
)
|
|
104
88
|
.subscribe()
|
|
105
|
-
this._channels.database.push({
|
|
106
|
-
name: trigger.name,
|
|
107
|
-
channel,
|
|
108
|
-
state: channel.state,
|
|
109
|
-
})
|
|
110
89
|
|
|
111
90
|
return params
|
|
112
91
|
} catch (err) {
|
|
@@ -143,7 +122,7 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
|
|
|
143
122
|
CloudWrapper.info(
|
|
144
123
|
`Set up Storage trigger ${trigger.name} for ${trigger.event} event`
|
|
145
124
|
)
|
|
146
|
-
|
|
125
|
+
this._supabaseClient
|
|
147
126
|
?.channel(trigger.name)
|
|
148
127
|
.on(
|
|
149
128
|
'postgres_changes',
|
|
@@ -166,11 +145,7 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
|
|
|
166
145
|
}
|
|
167
146
|
)
|
|
168
147
|
.subscribe()
|
|
169
|
-
|
|
170
|
-
name: trigger.name,
|
|
171
|
-
channel,
|
|
172
|
-
state: channel?.state,
|
|
173
|
-
})
|
|
148
|
+
|
|
174
149
|
return params
|
|
175
150
|
} catch (err) {
|
|
176
151
|
console.log(err)
|
|
@@ -207,15 +182,12 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
|
|
|
207
182
|
}
|
|
208
183
|
break
|
|
209
184
|
case 'timeout':
|
|
210
|
-
|
|
211
|
-
`⚠️ Supabase Realtime connection timed out`
|
|
212
|
-
)
|
|
213
|
-
break
|
|
185
|
+
case 'error':
|
|
214
186
|
case 'disconnected':
|
|
215
187
|
// Reconnect only if exitOnDisconnect is explicitly set to false.
|
|
216
188
|
if (this._params.exitOnDisconnect === false) {
|
|
217
189
|
CloudWrapper.warn(
|
|
218
|
-
`❌ Supabase connection lost. Attempting to reconnect...`
|
|
190
|
+
`❌ Supabase connection lost (status: ${status}). Attempting to reconnect...`
|
|
219
191
|
)
|
|
220
192
|
this._isInitialized = false
|
|
221
193
|
this._heartbeatOkReceived = false
|
|
@@ -223,7 +195,7 @@ export class SupabaseCloudWrapper extends AbstractCloudWrapper {
|
|
|
223
195
|
} else {
|
|
224
196
|
// Default behavior: exit to allow for a clean restart by the orchestrator.
|
|
225
197
|
CloudWrapper.error(
|
|
226
|
-
`❌ Supabase connection lost. Exiting to allow for a clean restart.`
|
|
198
|
+
`❌ Supabase connection lost (status: ${status}). Exiting to allow for a clean restart.`
|
|
227
199
|
)
|
|
228
200
|
process.exit(1)
|
|
229
201
|
}
|