@ankhorage/supabase-db 1.0.2 → 1.0.4
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/CHANGELOG.md +13 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +69 -63
- package/dist/adapter.js.map +1 -1
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js +34 -55
- package/dist/realtime.js.map +1 -1
- package/package.json +9 -11
- package/src/adapter.test.ts +108 -110
- package/src/adapter.ts +87 -109
- package/src/admin.test.ts +87 -91
- package/src/realtime.test.ts +102 -106
- package/src/realtime.ts +57 -74
package/src/admin.test.ts
CHANGED
|
@@ -1,110 +1,106 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
2
|
|
|
3
3
|
import { createSupabaseDbAdminAdapter } from './admin.js';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
serviceRoleKey: 'service-role',
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
const result = await adapter.createCollection({
|
|
13
|
-
name: 'posts',
|
|
14
|
-
fields: [
|
|
15
|
-
{ name: 'title', type: 'text', required: true },
|
|
16
|
-
{ name: 'like_count', type: 'number', defaultValue: 0 },
|
|
17
|
-
{ name: 'published', type: 'boolean', defaultValue: false },
|
|
18
|
-
],
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
expect(adapter.capabilities).toEqual({
|
|
22
|
-
schemaGeneration: true,
|
|
23
|
-
directExecution: false,
|
|
24
|
-
});
|
|
25
|
-
expect(result.ok).toBe(true);
|
|
26
|
-
expect(result.ok ? result.executed : true).toBe(false);
|
|
27
|
-
expect(result.ok ? result.sql : '').toContain('create table if not exists "public"."posts"');
|
|
28
|
-
expect(result.ok ? result.sql : '').toContain(
|
|
29
|
-
'"id" uuid primary key default gen_random_uuid()',
|
|
30
|
-
);
|
|
31
|
-
expect(result.ok ? result.sql : '').toContain('"title" text not null');
|
|
32
|
-
expect(result.ok ? result.sql : '').toContain('"like_count" double precision default 0');
|
|
33
|
-
expect(result.ok ? result.sql : '').toContain('"published" boolean default false');
|
|
5
|
+
test('generates create collection SQL without executing by default', async () => {
|
|
6
|
+
const adapter = createSupabaseDbAdminAdapter({
|
|
7
|
+
url: 'https://example.supabase.co',
|
|
8
|
+
serviceRoleKey: 'service-role',
|
|
34
9
|
});
|
|
35
10
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
executedSql.push(sql);
|
|
44
|
-
return Promise.resolve({ ok: true });
|
|
45
|
-
},
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
const result = await adapter.createCollection({
|
|
49
|
-
name: 'posts',
|
|
50
|
-
fields: [{ name: 'title', type: 'text' }],
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
expect(adapter.capabilities).toEqual({
|
|
54
|
-
schemaGeneration: true,
|
|
55
|
-
directExecution: true,
|
|
56
|
-
});
|
|
57
|
-
expect(result).toEqual({
|
|
58
|
-
ok: true,
|
|
59
|
-
sql: executedSql[0],
|
|
60
|
-
executed: true,
|
|
61
|
-
});
|
|
62
|
-
expect(executedSql).toHaveLength(1);
|
|
11
|
+
const result = await adapter.createCollection({
|
|
12
|
+
name: 'posts',
|
|
13
|
+
fields: [
|
|
14
|
+
{ name: 'title', type: 'text', required: true },
|
|
15
|
+
{ name: 'like_count', type: 'number', defaultValue: 0 },
|
|
16
|
+
{ name: 'published', type: 'boolean', defaultValue: false },
|
|
17
|
+
],
|
|
63
18
|
});
|
|
64
19
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
20
|
+
expect(adapter.capabilities).toEqual({
|
|
21
|
+
schemaGeneration: true,
|
|
22
|
+
directExecution: false,
|
|
23
|
+
});
|
|
24
|
+
expect(result.ok).toBe(true);
|
|
25
|
+
expect(result.ok ? result.executed : true).toBe(false);
|
|
26
|
+
expect(result.ok ? result.sql : '').toContain('create table if not exists "public"."posts"');
|
|
27
|
+
expect(result.ok ? result.sql : '').toContain('"id" uuid primary key default gen_random_uuid()');
|
|
28
|
+
expect(result.ok ? result.sql : '').toContain('"title" text not null');
|
|
29
|
+
expect(result.ok ? result.sql : '').toContain('"like_count" double precision default 0');
|
|
30
|
+
expect(result.ok ? result.sql : '').toContain('"published" boolean default false');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('executes generated SQL only when explicitly configured', async () => {
|
|
34
|
+
const executedSql: string[] = [];
|
|
35
|
+
const adapter = createSupabaseDbAdminAdapter({
|
|
36
|
+
url: 'https://example.supabase.co',
|
|
37
|
+
serviceRoleKey: 'service-role',
|
|
38
|
+
execute: true,
|
|
39
|
+
executeSql: (sql) => {
|
|
40
|
+
executedSql.push(sql);
|
|
41
|
+
return Promise.resolve({ ok: true });
|
|
42
|
+
},
|
|
43
|
+
});
|
|
71
44
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
45
|
+
const result = await adapter.createCollection({
|
|
46
|
+
name: 'posts',
|
|
47
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(adapter.capabilities).toEqual({
|
|
51
|
+
schemaGeneration: true,
|
|
52
|
+
directExecution: true,
|
|
53
|
+
});
|
|
54
|
+
expect(result).toEqual({
|
|
55
|
+
ok: true,
|
|
56
|
+
sql: executedSql[0],
|
|
57
|
+
executed: true,
|
|
58
|
+
});
|
|
59
|
+
expect(executedSql).toHaveLength(1);
|
|
60
|
+
});
|
|
76
61
|
|
|
77
|
-
|
|
78
|
-
|
|
62
|
+
test('refuses execution without a service role key', async () => {
|
|
63
|
+
const adapter = createSupabaseDbAdminAdapter({
|
|
64
|
+
url: 'https://example.supabase.co',
|
|
65
|
+
execute: true,
|
|
66
|
+
executeSql: () => Promise.resolve({ ok: true }),
|
|
79
67
|
});
|
|
80
68
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
});
|
|
69
|
+
const result = await adapter.createCollection({
|
|
70
|
+
name: 'posts',
|
|
71
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
72
|
+
});
|
|
86
73
|
|
|
87
|
-
|
|
74
|
+
expect(result.ok).toBe(false);
|
|
75
|
+
expect(result.ok === false ? result.error.code : '').toBe('missing_service_role_key');
|
|
76
|
+
});
|
|
88
77
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
});
|
|
78
|
+
test('generates delete collection SQL', () => {
|
|
79
|
+
const adapter = createSupabaseDbAdminAdapter({
|
|
80
|
+
url: 'https://example.supabase.co',
|
|
81
|
+
serviceRoleKey: 'service-role',
|
|
94
82
|
});
|
|
95
83
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
84
|
+
const result = adapter.generateDeleteCollectionSql({ name: 'posts' });
|
|
85
|
+
|
|
86
|
+
expect(result).toEqual({
|
|
87
|
+
ok: true,
|
|
88
|
+
sql: 'drop table if exists "public"."posts";',
|
|
89
|
+
executed: false,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
101
92
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
93
|
+
test('rejects invalid collection names', () => {
|
|
94
|
+
const adapter = createSupabaseDbAdminAdapter({
|
|
95
|
+
url: 'https://example.supabase.co',
|
|
96
|
+
serviceRoleKey: 'service-role',
|
|
97
|
+
});
|
|
106
98
|
|
|
107
|
-
|
|
108
|
-
|
|
99
|
+
const result = adapter.generateCreateCollectionSql({
|
|
100
|
+
name: 'invalid-name',
|
|
101
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
109
102
|
});
|
|
103
|
+
|
|
104
|
+
expect(result.ok).toBe(false);
|
|
105
|
+
expect(result.ok === false ? result.error.code : '').toBe('validation_error');
|
|
110
106
|
});
|
package/src/realtime.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
2
|
|
|
3
3
|
import { createSupabaseDbAdapter } from './adapter.js';
|
|
4
4
|
import { normalizeRealtimeEvent } from './realtime.js';
|
|
@@ -13,125 +13,121 @@ interface PostRecord {
|
|
|
13
13
|
readonly title: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
eventType: 'INSERT',
|
|
21
|
-
schema: 'public',
|
|
22
|
-
table: 'posts',
|
|
23
|
-
commit_timestamp: '2026-05-12T10:00:00Z',
|
|
24
|
-
new: { id: 'post-1', title: 'Hello' },
|
|
25
|
-
},
|
|
26
|
-
'fallback',
|
|
27
|
-
'public',
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
expect(event).toEqual({
|
|
31
|
-
table: 'posts',
|
|
16
|
+
test('normalizes insert payloads', () => {
|
|
17
|
+
const event = normalizeRealtimeEvent<PostRecord>(
|
|
18
|
+
{
|
|
19
|
+
eventType: 'INSERT',
|
|
32
20
|
schema: 'public',
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
21
|
+
table: 'posts',
|
|
22
|
+
commit_timestamp: '2026-05-12T10:00:00Z',
|
|
23
|
+
new: { id: 'post-1', title: 'Hello' },
|
|
24
|
+
},
|
|
25
|
+
'fallback',
|
|
26
|
+
'public',
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(event).toEqual({
|
|
30
|
+
table: 'posts',
|
|
31
|
+
schema: 'public',
|
|
32
|
+
kind: 'insert',
|
|
33
|
+
record: { id: 'post-1', title: 'Hello' },
|
|
34
|
+
committedAt: '2026-05-12T10:00:00Z',
|
|
37
35
|
});
|
|
36
|
+
});
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
});
|
|
38
|
+
test('normalizes update payloads with previous record', () => {
|
|
39
|
+
const event = normalizeRealtimeEvent<PostRecord>(
|
|
40
|
+
{
|
|
41
|
+
eventType: 'UPDATE',
|
|
42
|
+
new: { id: 'post-1', title: 'Updated' },
|
|
43
|
+
old: { id: 'post-1', title: 'Old' },
|
|
44
|
+
},
|
|
45
|
+
'posts',
|
|
46
|
+
'public',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(event).toEqual({
|
|
50
|
+
table: 'posts',
|
|
51
|
+
schema: 'public',
|
|
52
|
+
kind: 'update',
|
|
53
|
+
record: { id: 'post-1', title: 'Updated' },
|
|
54
|
+
previousRecord: { id: 'post-1', title: 'Old' },
|
|
55
|
+
committedAt: undefined,
|
|
58
56
|
});
|
|
57
|
+
});
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
});
|
|
59
|
+
test('normalizes delete payloads to a null current record', () => {
|
|
60
|
+
const event = normalizeRealtimeEvent<PostRecord>(
|
|
61
|
+
{
|
|
62
|
+
eventType: 'DELETE',
|
|
63
|
+
old: { id: 'post-1', title: 'Deleted' },
|
|
64
|
+
},
|
|
65
|
+
'posts',
|
|
66
|
+
'public',
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(event).toEqual({
|
|
70
|
+
table: 'posts',
|
|
71
|
+
schema: 'public',
|
|
72
|
+
kind: 'delete',
|
|
73
|
+
record: null,
|
|
74
|
+
previousRecord: { id: 'post-1', title: 'Deleted' },
|
|
75
|
+
committedAt: undefined,
|
|
78
76
|
});
|
|
77
|
+
});
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
test('ignores unknown realtime events', () => {
|
|
80
|
+
const event = normalizeRealtimeEvent<PostRecord>({ eventType: 'TRUNCATE' }, 'posts', 'public');
|
|
82
81
|
|
|
83
|
-
|
|
84
|
-
});
|
|
82
|
+
expect(event).toBeNull();
|
|
85
83
|
});
|
|
86
84
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
const received: unknown[] = [];
|
|
99
|
-
const subscription = adapter.realtime?.subscribeToCollection<PostRecord>(
|
|
100
|
-
{ table: 'posts' },
|
|
101
|
-
(event) => {
|
|
102
|
-
received.push(event);
|
|
103
|
-
},
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
client.emit({
|
|
107
|
-
eventType: 'INSERT',
|
|
108
|
-
table: 'posts',
|
|
109
|
-
schema: 'public',
|
|
110
|
-
new: { id: '1', title: 'A' },
|
|
111
|
-
});
|
|
112
|
-
await subscription?.unsubscribe();
|
|
85
|
+
test('subscribes and cleans up collection channels', async () => {
|
|
86
|
+
const client = createFakeRealtimeClient();
|
|
87
|
+
const adapter = createSupabaseDbAdapter({
|
|
88
|
+
url: 'https://example.supabase.co',
|
|
89
|
+
anonKey: 'anon',
|
|
90
|
+
realtime: true,
|
|
91
|
+
realtimeClient: client,
|
|
92
|
+
fetch: () => Promise.resolve(new Response('[]')),
|
|
93
|
+
});
|
|
113
94
|
|
|
114
|
-
|
|
115
|
-
|
|
95
|
+
const received: unknown[] = [];
|
|
96
|
+
const subscription = adapter.realtime?.subscribeToCollection<PostRecord>(
|
|
97
|
+
{ table: 'posts' },
|
|
98
|
+
(event) => {
|
|
99
|
+
received.push(event);
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
client.emit({
|
|
104
|
+
eventType: 'INSERT',
|
|
105
|
+
table: 'posts',
|
|
106
|
+
schema: 'public',
|
|
107
|
+
new: { id: '1', title: 'A' },
|
|
116
108
|
});
|
|
109
|
+
await subscription?.unsubscribe();
|
|
110
|
+
|
|
111
|
+
expect(received).toHaveLength(1);
|
|
112
|
+
expect(client.removedChannels).toBe(1);
|
|
113
|
+
});
|
|
117
114
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
adapter.realtime?.subscribeToRecord<PostRecord>(
|
|
129
|
-
{ table: 'posts', id: 'post-1' },
|
|
130
|
-
() => undefined,
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
expect(client.lastFilter?.filter).toBe('id=eq.post-1');
|
|
115
|
+
test('subscribes to a specific record using a Supabase filter', () => {
|
|
116
|
+
const client = createFakeRealtimeClient();
|
|
117
|
+
const adapter = createSupabaseDbAdapter({
|
|
118
|
+
url: 'https://example.supabase.co',
|
|
119
|
+
anonKey: 'anon',
|
|
120
|
+
realtime: true,
|
|
121
|
+
realtimeClient: client,
|
|
122
|
+
fetch: () => Promise.resolve(new Response('[]')),
|
|
134
123
|
});
|
|
124
|
+
|
|
125
|
+
adapter.realtime?.subscribeToRecord<PostRecord>(
|
|
126
|
+
{ table: 'posts', id: 'post-1' },
|
|
127
|
+
() => undefined,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(client.lastFilter?.filter).toBe('id=eq.post-1');
|
|
135
131
|
});
|
|
136
132
|
|
|
137
133
|
function createFakeRealtimeClient(): SupabaseRealtimeClient & {
|
package/src/realtime.ts
CHANGED
|
@@ -23,66 +23,13 @@ export function createRealtimeApi(config: RealtimeApiConfig): DbRealtimeAdapter[
|
|
|
23
23
|
input: DbCollectionSubscriptionInput,
|
|
24
24
|
listener: DbChangeListener<TRecord>,
|
|
25
25
|
): DbSubscription {
|
|
26
|
-
|
|
27
|
-
const table = validateIdentifier(input.table, 'Realtime table');
|
|
28
|
-
const channel = config.client
|
|
29
|
-
.channel(`ankhorage-db:${schema}:${table}`)
|
|
30
|
-
.on(
|
|
31
|
-
'postgres_changes',
|
|
32
|
-
{
|
|
33
|
-
event: '*',
|
|
34
|
-
schema,
|
|
35
|
-
table,
|
|
36
|
-
},
|
|
37
|
-
(payload) => {
|
|
38
|
-
const event = normalizeRealtimeEvent<TRecord>(payload, table, schema);
|
|
39
|
-
|
|
40
|
-
if (event !== null) {
|
|
41
|
-
listener(event);
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
)
|
|
45
|
-
.subscribe();
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
async unsubscribe(): Promise<void> {
|
|
49
|
-
await config.client.removeChannel(channel);
|
|
50
|
-
},
|
|
51
|
-
};
|
|
26
|
+
return createCollectionSubscription(config, input, listener);
|
|
52
27
|
},
|
|
53
|
-
|
|
54
28
|
subscribeToRecord<TRecord extends object = DbRecord>(
|
|
55
29
|
input: DbRecordSubscriptionInput,
|
|
56
30
|
listener: DbChangeListener<TRecord>,
|
|
57
31
|
): DbSubscription {
|
|
58
|
-
|
|
59
|
-
const table = validateIdentifier(input.table, 'Realtime table');
|
|
60
|
-
const idField = validateIdentifier(input.idField ?? 'id', 'Realtime record id field');
|
|
61
|
-
const channel = config.client
|
|
62
|
-
.channel(`ankhorage-db:${schema}:${table}:${idField}:${String(input.id)}`)
|
|
63
|
-
.on(
|
|
64
|
-
'postgres_changes',
|
|
65
|
-
{
|
|
66
|
-
event: '*',
|
|
67
|
-
schema,
|
|
68
|
-
table,
|
|
69
|
-
filter: `${idField}=eq.${String(input.id)}`,
|
|
70
|
-
},
|
|
71
|
-
(payload) => {
|
|
72
|
-
const event = normalizeRealtimeEvent<TRecord>(payload, table, schema);
|
|
73
|
-
|
|
74
|
-
if (event !== null) {
|
|
75
|
-
listener(event);
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
|
-
)
|
|
79
|
-
.subscribe();
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
async unsubscribe(): Promise<void> {
|
|
83
|
-
await config.client.removeChannel(channel);
|
|
84
|
-
},
|
|
85
|
-
};
|
|
32
|
+
return createRecordSubscription(config, input, listener);
|
|
86
33
|
},
|
|
87
34
|
};
|
|
88
35
|
}
|
|
@@ -93,11 +40,7 @@ export function normalizeRealtimeEvent<TRecord extends object = DbRecord>(
|
|
|
93
40
|
fallbackSchema: string,
|
|
94
41
|
): DbChangeEvent<TRecord> | null {
|
|
95
42
|
const kind = normalizeKind(payload.eventType);
|
|
96
|
-
|
|
97
|
-
if (kind === null) {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
|
|
43
|
+
if (kind === null) return null;
|
|
101
44
|
const record = normalizeRecord<TRecord>(payload.new);
|
|
102
45
|
const previousRecord = normalizeRecord<TRecord>(payload.old);
|
|
103
46
|
const base = {
|
|
@@ -107,17 +50,65 @@ export function normalizeRealtimeEvent<TRecord extends object = DbRecord>(
|
|
|
107
50
|
record: kind === 'delete' ? null : record,
|
|
108
51
|
committedAt: payload.commit_timestamp,
|
|
109
52
|
};
|
|
53
|
+
if (previousRecord === null) return base;
|
|
54
|
+
return { ...base, previousRecord };
|
|
55
|
+
}
|
|
110
56
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
57
|
+
function createCollectionSubscription<TRecord extends object>(
|
|
58
|
+
config: RealtimeApiConfig,
|
|
59
|
+
input: DbCollectionSubscriptionInput,
|
|
60
|
+
listener: DbChangeListener<TRecord>,
|
|
61
|
+
): DbSubscription {
|
|
62
|
+
const schema = input.schema ?? config.defaultSchema;
|
|
63
|
+
const table = validateIdentifier(input.table, 'Realtime table');
|
|
64
|
+
return createSubscription(config, `ankhorage-db:${schema}:${table}`, schema, table, listener);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createRecordSubscription<TRecord extends object>(
|
|
68
|
+
config: RealtimeApiConfig,
|
|
69
|
+
input: DbRecordSubscriptionInput,
|
|
70
|
+
listener: DbChangeListener<TRecord>,
|
|
71
|
+
): DbSubscription {
|
|
72
|
+
const schema = input.schema ?? config.defaultSchema;
|
|
73
|
+
const table = validateIdentifier(input.table, 'Realtime table');
|
|
74
|
+
const idField = validateIdentifier(input.idField ?? 'id', 'Realtime record id field');
|
|
75
|
+
const id = String(input.id);
|
|
76
|
+
return createSubscription(
|
|
77
|
+
config,
|
|
78
|
+
`ankhorage-db:${schema}:${table}:${idField}:${id}`,
|
|
79
|
+
schema,
|
|
80
|
+
table,
|
|
81
|
+
listener,
|
|
82
|
+
`${idField}=eq.${id}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
114
85
|
|
|
86
|
+
function createSubscription<TRecord extends object>(
|
|
87
|
+
config: RealtimeApiConfig,
|
|
88
|
+
channelName: string,
|
|
89
|
+
schema: string,
|
|
90
|
+
table: string,
|
|
91
|
+
listener: DbChangeListener<TRecord>,
|
|
92
|
+
filter?: string,
|
|
93
|
+
): DbSubscription {
|
|
94
|
+
const channel = config.client
|
|
95
|
+
.channel(channelName)
|
|
96
|
+
.on('postgres_changes', { event: '*', schema, table, filter }, (payload) => {
|
|
97
|
+
const event = normalizeRealtimeEvent<TRecord>(payload, table, schema);
|
|
98
|
+
if (event !== null) listener(event);
|
|
99
|
+
})
|
|
100
|
+
.subscribe();
|
|
115
101
|
return {
|
|
116
|
-
|
|
117
|
-
|
|
102
|
+
async unsubscribe(): Promise<void> {
|
|
103
|
+
await config.client.removeChannel(channel);
|
|
104
|
+
},
|
|
118
105
|
};
|
|
119
106
|
}
|
|
120
107
|
|
|
108
|
+
function isRecord<TRecord extends object>(value: unknown): value is TRecord {
|
|
109
|
+
return typeof value === 'object' && value !== null;
|
|
110
|
+
}
|
|
111
|
+
|
|
121
112
|
function normalizeKind(value: string | undefined): DbChangeKind | null {
|
|
122
113
|
switch (value) {
|
|
123
114
|
case 'INSERT':
|
|
@@ -132,13 +123,5 @@ function normalizeKind(value: string | undefined): DbChangeKind | null {
|
|
|
132
123
|
}
|
|
133
124
|
|
|
134
125
|
function normalizeRecord<TRecord extends object>(value: unknown): TRecord | null {
|
|
135
|
-
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return value;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function isRecord<TRecord extends object>(value: unknown): value is TRecord {
|
|
143
|
-
return typeof value === 'object' && value !== null;
|
|
126
|
+
return isRecord<TRecord>(value) ? value : null;
|
|
144
127
|
}
|