@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/src/admin.test.ts CHANGED
@@ -1,110 +1,106 @@
1
- import { describe, expect, test } from 'bun:test';
1
+ import { expect, test } from 'bun:test';
2
2
 
3
3
  import { createSupabaseDbAdminAdapter } from './admin.js';
4
4
 
5
- describe('createSupabaseDbAdminAdapter', () => {
6
- test('generates create collection SQL without executing by default', async () => {
7
- const adapter = createSupabaseDbAdminAdapter({
8
- url: 'https://example.supabase.co',
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
- test('executes generated SQL only when explicitly configured', async () => {
37
- const executedSql: string[] = [];
38
- const adapter = createSupabaseDbAdminAdapter({
39
- url: 'https://example.supabase.co',
40
- serviceRoleKey: 'service-role',
41
- execute: true,
42
- executeSql: (sql) => {
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
- test('refuses execution without a service role key', async () => {
66
- const adapter = createSupabaseDbAdminAdapter({
67
- url: 'https://example.supabase.co',
68
- execute: true,
69
- executeSql: () => Promise.resolve({ ok: true }),
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
- const result = await adapter.createCollection({
73
- name: 'posts',
74
- fields: [{ name: 'title', type: 'text' }],
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
- expect(result.ok).toBe(false);
78
- expect(result.ok === false ? result.error.code : '').toBe('missing_service_role_key');
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
- test('generates delete collection SQL', () => {
82
- const adapter = createSupabaseDbAdminAdapter({
83
- url: 'https://example.supabase.co',
84
- serviceRoleKey: 'service-role',
85
- });
69
+ const result = await adapter.createCollection({
70
+ name: 'posts',
71
+ fields: [{ name: 'title', type: 'text' }],
72
+ });
86
73
 
87
- const result = adapter.generateDeleteCollectionSql({ name: 'posts' });
74
+ expect(result.ok).toBe(false);
75
+ expect(result.ok === false ? result.error.code : '').toBe('missing_service_role_key');
76
+ });
88
77
 
89
- expect(result).toEqual({
90
- ok: true,
91
- sql: 'drop table if exists "public"."posts";',
92
- executed: false,
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
- test('rejects invalid collection names', () => {
97
- const adapter = createSupabaseDbAdminAdapter({
98
- url: 'https://example.supabase.co',
99
- serviceRoleKey: 'service-role',
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
- const result = adapter.generateCreateCollectionSql({
103
- name: 'invalid-name',
104
- fields: [{ name: 'title', type: 'text' }],
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
- expect(result.ok).toBe(false);
108
- expect(result.ok === false ? result.error.code : '').toBe('validation_error');
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
  });
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'bun:test';
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
- describe('normalizeRealtimeEvent', () => {
17
- test('normalizes insert payloads', () => {
18
- const event = normalizeRealtimeEvent<PostRecord>(
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
- kind: 'insert',
34
- record: { id: 'post-1', title: 'Hello' },
35
- committedAt: '2026-05-12T10:00:00Z',
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
- test('normalizes update payloads with previous record', () => {
40
- const event = normalizeRealtimeEvent<PostRecord>(
41
- {
42
- eventType: 'UPDATE',
43
- new: { id: 'post-1', title: 'Updated' },
44
- old: { id: 'post-1', title: 'Old' },
45
- },
46
- 'posts',
47
- 'public',
48
- );
49
-
50
- expect(event).toEqual({
51
- table: 'posts',
52
- schema: 'public',
53
- kind: 'update',
54
- record: { id: 'post-1', title: 'Updated' },
55
- previousRecord: { id: 'post-1', title: 'Old' },
56
- committedAt: undefined,
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
- test('normalizes delete payloads to a null current record', () => {
61
- const event = normalizeRealtimeEvent<PostRecord>(
62
- {
63
- eventType: 'DELETE',
64
- old: { id: 'post-1', title: 'Deleted' },
65
- },
66
- 'posts',
67
- 'public',
68
- );
69
-
70
- expect(event).toEqual({
71
- table: 'posts',
72
- schema: 'public',
73
- kind: 'delete',
74
- record: null,
75
- previousRecord: { id: 'post-1', title: 'Deleted' },
76
- committedAt: undefined,
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
- test('ignores unknown realtime events', () => {
81
- const event = normalizeRealtimeEvent<PostRecord>({ eventType: 'TRUNCATE' }, 'posts', 'public');
79
+ test('ignores unknown realtime events', () => {
80
+ const event = normalizeRealtimeEvent<PostRecord>({ eventType: 'TRUNCATE' }, 'posts', 'public');
82
81
 
83
- expect(event).toBeNull();
84
- });
82
+ expect(event).toBeNull();
85
83
  });
86
84
 
87
- describe('realtime adapter capability', () => {
88
- test('subscribes and cleans up collection channels', async () => {
89
- const client = createFakeRealtimeClient();
90
- const adapter = createSupabaseDbAdapter({
91
- url: 'https://example.supabase.co',
92
- anonKey: 'anon',
93
- realtime: true,
94
- realtimeClient: client,
95
- fetch: () => Promise.resolve(new Response('[]')),
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
- expect(received).toHaveLength(1);
115
- expect(client.removedChannels).toBe(1);
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
- test('subscribes to a specific record using a Supabase filter', () => {
119
- const client = createFakeRealtimeClient();
120
- const adapter = createSupabaseDbAdapter({
121
- url: 'https://example.supabase.co',
122
- anonKey: 'anon',
123
- realtime: true,
124
- realtimeClient: client,
125
- fetch: () => Promise.resolve(new Response('[]')),
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
- const schema = input.schema ?? config.defaultSchema;
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
- const schema = input.schema ?? config.defaultSchema;
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
- if (previousRecord === null) {
112
- return base;
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
- ...base,
117
- previousRecord,
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
- if (!isRecord<TRecord>(value)) {
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
  }