@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.
@@ -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
 
@@ -12,139 +12,137 @@ interface PostRecord {
12
12
  readonly title: string;
13
13
  }
14
14
 
15
- describe('createSupabaseDbAdapter', () => {
16
- test('maps select input to Supabase PostgREST query params', async () => {
17
- const calls: FetchCall[] = [];
18
- const adapter = createSupabaseDbAdapter({
19
- url: 'https://example.supabase.co',
20
- anonKey: 'anon',
21
- fetch: (input, init) => {
22
- calls.push({ url: fetchInputToString(input), init });
23
- return Promise.resolve(jsonResponse([{ id: 'post-1', title: 'Hello' }]));
24
- },
25
- });
26
-
27
- const result = await adapter.select<PostRecord>({
28
- table: 'posts',
29
- columns: ['id', 'title'],
30
- filters: [{ field: 'title', operator: 'startsWith', value: 'Hel' }],
31
- sort: [{ field: 'title', direction: 'desc' }],
32
- page: { limit: 10, offset: 5 },
33
- });
34
-
35
- expect(result).toEqual({ ok: true, data: [{ id: 'post-1', title: 'Hello' }] });
36
- expect(calls).toHaveLength(1);
37
-
38
- const url = new URL(calls[0]?.url ?? 'https://invalid.test');
39
- expect(url.pathname).toBe('/rest/v1/posts');
40
- expect(url.searchParams.get('select')).toBe('id,title');
41
- expect(url.searchParams.get('title')).toBe('like.Hel*');
42
- expect(url.searchParams.get('order')).toBe('title.desc');
43
- expect(url.searchParams.get('limit')).toBe('10');
44
- expect(url.searchParams.get('offset')).toBe('5');
45
- expect(readHeader(calls[0]?.init, 'apikey')).toBe('anon');
46
- expect(readHeader(calls[0]?.init, 'Prefer')).toBe('return=representation');
15
+ test('maps select input to Supabase PostgREST query params', async () => {
16
+ const calls: FetchCall[] = [];
17
+ const adapter = createSupabaseDbAdapter({
18
+ url: 'https://example.supabase.co',
19
+ anonKey: 'anon',
20
+ fetch: (input, init) => {
21
+ calls.push({ url: fetchInputToString(input), init });
22
+ return Promise.resolve(jsonResponse([{ id: 'post-1', title: 'Hello' }]));
23
+ },
47
24
  });
48
25
 
49
- test('finds records by id', async () => {
50
- const adapter = createSupabaseDbAdapter({
51
- url: 'https://example.supabase.co',
52
- anonKey: 'anon',
53
- fetch: () => Promise.resolve(jsonResponse([{ id: 'post-1', title: 'Hello' }])),
54
- });
26
+ const result = await adapter.select<PostRecord>({
27
+ table: 'posts',
28
+ columns: ['id', 'title'],
29
+ filters: [{ field: 'title', operator: 'startsWith', value: 'Hel' }],
30
+ sort: [{ field: 'title', direction: 'desc' }],
31
+ page: { limit: 10, offset: 5 },
32
+ });
33
+
34
+ expect(result).toEqual({ ok: true, data: [{ id: 'post-1', title: 'Hello' }] });
35
+ expect(calls).toHaveLength(1);
36
+
37
+ const url = new URL(calls[0]?.url ?? 'https://invalid.test');
38
+ expect(url.pathname).toBe('/rest/v1/posts');
39
+ expect(url.searchParams.get('select')).toBe('id,title');
40
+ expect(url.searchParams.get('title')).toBe('like.Hel*');
41
+ expect(url.searchParams.get('order')).toBe('title.desc');
42
+ expect(url.searchParams.get('limit')).toBe('10');
43
+ expect(url.searchParams.get('offset')).toBe('5');
44
+ expect(readHeader(calls[0]?.init, 'apikey')).toBe('anon');
45
+ expect(readHeader(calls[0]?.init, 'Prefer')).toBe('return=representation');
46
+ });
47
+
48
+ test('finds records by id', async () => {
49
+ const adapter = createSupabaseDbAdapter({
50
+ url: 'https://example.supabase.co',
51
+ anonKey: 'anon',
52
+ fetch: () => Promise.resolve(jsonResponse([{ id: 'post-1', title: 'Hello' }])),
53
+ });
54
+
55
+ const result = await adapter.findById<PostRecord>({ table: 'posts', id: 'post-1' });
55
56
 
56
- const result = await adapter.findById<PostRecord>({ table: 'posts', id: 'post-1' });
57
+ expect(result).toEqual({ ok: true, data: { id: 'post-1', title: 'Hello' } });
58
+ });
57
59
 
58
- expect(result).toEqual({ ok: true, data: { id: 'post-1', title: 'Hello' } });
60
+ test('normalizes empty findById result to null', async () => {
61
+ const adapter = createSupabaseDbAdapter({
62
+ url: 'https://example.supabase.co',
63
+ anonKey: 'anon',
64
+ fetch: () => Promise.resolve(jsonResponse([])),
59
65
  });
60
66
 
61
- test('normalizes empty findById result to null', async () => {
62
- const adapter = createSupabaseDbAdapter({
63
- url: 'https://example.supabase.co',
64
- anonKey: 'anon',
65
- fetch: () => Promise.resolve(jsonResponse([])),
66
- });
67
+ const result = await adapter.findById<PostRecord>({ table: 'posts', id: 'missing' });
67
68
 
68
- const result = await adapter.findById<PostRecord>({ table: 'posts', id: 'missing' });
69
+ expect(result).toEqual({ ok: true, data: null });
70
+ });
69
71
 
70
- expect(result).toEqual({ ok: true, data: null });
72
+ test('maps insert values to POST with returning representation', async () => {
73
+ const calls: FetchCall[] = [];
74
+ const adapter = createSupabaseDbAdapter({
75
+ url: 'https://example.supabase.co',
76
+ anonKey: 'anon',
77
+ fetch: (input, init) => {
78
+ calls.push({ url: fetchInputToString(input), init });
79
+ return Promise.resolve(jsonResponse([{ id: 'post-1', title: 'Hello' }]));
80
+ },
71
81
  });
72
82
 
73
- test('maps insert values to POST with returning representation', async () => {
74
- const calls: FetchCall[] = [];
75
- const adapter = createSupabaseDbAdapter({
76
- url: 'https://example.supabase.co',
77
- anonKey: 'anon',
78
- fetch: (input, init) => {
79
- calls.push({ url: fetchInputToString(input), init });
80
- return Promise.resolve(jsonResponse([{ id: 'post-1', title: 'Hello' }]));
81
- },
82
- });
83
-
84
- const result = await adapter.insert<PostRecord>({
85
- table: 'posts',
86
- values: { id: 'post-1', title: 'Hello' },
87
- });
88
-
89
- expect(result.ok).toBe(true);
90
- expect(calls[0]?.init?.method).toBe('POST');
91
- expect(calls[0]?.init?.body).toBe(JSON.stringify({ id: 'post-1', title: 'Hello' }));
83
+ const result = await adapter.insert<PostRecord>({
84
+ table: 'posts',
85
+ values: { id: 'post-1', title: 'Hello' },
92
86
  });
93
87
 
94
- test('requires filters for update', async () => {
95
- const adapter = createSupabaseDbAdapter({
96
- url: 'https://example.supabase.co',
97
- anonKey: 'anon',
98
- fetch: () => Promise.resolve(jsonResponse([])),
99
- });
100
-
101
- const result = await adapter.update<PostRecord>({
102
- table: 'posts',
103
- values: { title: 'Updated' },
104
- filters: [],
105
- });
106
-
107
- expect(result.ok).toBe(false);
108
- expect(result.ok === false ? result.error.code : '').toBe('validation_error');
88
+ expect(result.ok).toBe(true);
89
+ expect(calls[0]?.init?.method).toBe('POST');
90
+ expect(calls[0]?.init?.body).toBe(JSON.stringify({ id: 'post-1', title: 'Hello' }));
91
+ });
92
+
93
+ test('requires filters for update', async () => {
94
+ const adapter = createSupabaseDbAdapter({
95
+ url: 'https://example.supabase.co',
96
+ anonKey: 'anon',
97
+ fetch: () => Promise.resolve(jsonResponse([])),
109
98
  });
110
99
 
111
- test('requires filters for delete', async () => {
112
- const adapter = createSupabaseDbAdapter({
113
- url: 'https://example.supabase.co',
114
- anonKey: 'anon',
115
- fetch: () => Promise.resolve(jsonResponse([])),
116
- });
100
+ const result = await adapter.update<PostRecord>({
101
+ table: 'posts',
102
+ values: { title: 'Updated' },
103
+ filters: [],
104
+ });
117
105
 
118
- const result = await adapter.delete<PostRecord>({ table: 'posts', filters: [] });
106
+ expect(result.ok).toBe(false);
107
+ expect(result.ok === false ? result.error.code : '').toBe('validation_error');
108
+ });
119
109
 
120
- expect(result.ok).toBe(false);
121
- expect(result.ok === false ? result.error.code : '').toBe('validation_error');
110
+ test('requires filters for delete', async () => {
111
+ const adapter = createSupabaseDbAdapter({
112
+ url: 'https://example.supabase.co',
113
+ anonKey: 'anon',
114
+ fetch: () => Promise.resolve(jsonResponse([])),
122
115
  });
123
116
 
124
- test('normalizes provider errors', async () => {
125
- const adapter = createSupabaseDbAdapter({
126
- url: 'https://example.supabase.co',
127
- anonKey: 'anon',
128
- fetch: () => Promise.resolve(jsonResponse({ message: 'permission denied' }, 403)),
129
- });
117
+ const result = await adapter.delete<PostRecord>({ table: 'posts', filters: [] });
130
118
 
131
- const result = await adapter.select<PostRecord>({ table: 'posts' });
119
+ expect(result.ok).toBe(false);
120
+ expect(result.ok === false ? result.error.code : '').toBe('validation_error');
121
+ });
132
122
 
133
- expect(result.ok).toBe(false);
134
- expect(result.ok === false ? result.error.code : '').toBe('permission_denied');
123
+ test('normalizes provider errors', async () => {
124
+ const adapter = createSupabaseDbAdapter({
125
+ url: 'https://example.supabase.co',
126
+ anonKey: 'anon',
127
+ fetch: () => Promise.resolve(jsonResponse({ message: 'permission denied' }, 403)),
135
128
  });
136
129
 
137
- test('exposes realtime capability only when configured with a client', () => {
138
- const adapter = createSupabaseDbAdapter({
139
- url: 'https://example.supabase.co',
140
- anonKey: 'anon',
141
- realtime: false,
142
- fetch: () => Promise.resolve(jsonResponse([])),
143
- });
130
+ const result = await adapter.select<PostRecord>({ table: 'posts' });
131
+
132
+ expect(result.ok).toBe(false);
133
+ expect(result.ok === false ? result.error.code : '').toBe('permission_denied');
134
+ });
144
135
 
145
- expect(adapter.capabilities.realtime).toBe(false);
146
- expect('realtime' in adapter).toBe(false);
136
+ test('exposes realtime capability only when configured with a client', () => {
137
+ const adapter = createSupabaseDbAdapter({
138
+ url: 'https://example.supabase.co',
139
+ anonKey: 'anon',
140
+ realtime: false,
141
+ fetch: () => Promise.resolve(jsonResponse([])),
147
142
  });
143
+
144
+ expect(adapter.capabilities.realtime).toBe(false);
145
+ expect('realtime' in adapter).toBe(false);
148
146
  });
149
147
 
150
148
  function jsonResponse(body: unknown, status = 200): Response {
package/src/adapter.ts CHANGED
@@ -33,113 +33,91 @@ interface DbFindByIdSuccessResult<TRecord extends object> {
33
33
 
34
34
  export function createSupabaseDbAdapter(options: SupabaseDbAdapterOptions): SupabaseDbAdapter {
35
35
  const config = normalizeConfig(options);
36
- const baseAdapter: DbAdapter = {
36
+ const baseAdapter = createBaseAdapter(config);
37
+ if (!config.realtime || config.realtimeClient === undefined) return baseAdapter;
38
+ return {
39
+ ...baseAdapter,
40
+ realtime: createRealtimeApi({
41
+ client: config.realtimeClient,
42
+ defaultSchema: config.schema,
43
+ }),
44
+ };
45
+ }
46
+
47
+ function createBaseAdapter(config: NormalizedConfig): DbAdapter {
48
+ return {
37
49
  capabilities: {
38
50
  transactions: false,
39
51
  returning: true,
40
52
  realtime: config.realtime && config.realtimeClient !== undefined,
41
53
  },
42
-
43
- async select<TRecord extends object = DbRecord>(
44
- input: DbSelectInput,
45
- ): Promise<DbResult<TRecord[]>> {
46
- return requestRows<TRecord>(config, buildSelectUrl(config.url, input), {
47
- method: 'GET',
48
- });
54
+ select<TRecord extends object = DbRecord>(input: DbSelectInput) {
55
+ return selectRows<TRecord>(config, input);
49
56
  },
50
-
51
- async findById<TRecord extends object = DbRecord>(
52
- input: DbFindByIdInput,
53
- ): Promise<DbResult<TRecord | null>> {
54
- const result = await requestRows<TRecord>(
55
- config,
56
- buildSelectUrl(config.url, {
57
- table: input.table,
58
- columns: input.columns,
59
- filters: [{ field: input.idField ?? 'id', operator: 'eq', value: input.id }],
60
- page: { limit: 1 },
61
- schema: input.schema,
62
- }),
63
- { method: 'GET' },
64
- );
65
-
66
- if (!result.ok) {
67
- return result;
68
- }
69
-
70
- const success: DbFindByIdSuccessResult<TRecord> = {
71
- ok: true,
72
- data: result.data[0] ?? null,
73
- };
74
-
75
- return success;
57
+ findById<TRecord extends object = DbRecord>(input: DbFindByIdInput) {
58
+ return findRowById<TRecord>(config, input);
76
59
  },
77
-
78
- async insert<TRecord extends object = DbRecord>(
79
- input: DbInsertInput<TRecord>,
80
- ): Promise<DbResult<TRecord[]>> {
81
- const url = buildMutationUrl(config.url, input.table, []);
82
-
83
- return requestRows<TRecord>(config, url, {
84
- method: 'POST',
85
- body: JSON.stringify(input.values),
86
- });
60
+ insert<TRecord extends object = DbRecord>(input: DbInsertInput<TRecord>) {
61
+ return insertRows<TRecord>(config, input);
87
62
  },
88
-
89
- async update<TRecord extends object = DbRecord>(
90
- input: DbUpdateInput<TRecord>,
91
- ): Promise<DbResult<TRecord[]>> {
92
- const validationError = validateRequiredFilters(input.filters, 'Update');
93
-
94
- if (validationError !== null) {
95
- return validationError;
96
- }
97
-
98
- const url = buildMutationUrl(config.url, input.table, input.filters);
99
-
100
- return requestRows<TRecord>(config, url, {
101
- method: 'PATCH',
102
- body: JSON.stringify(input.values),
103
- });
63
+ update<TRecord extends object = DbRecord>(input: DbUpdateInput<TRecord>) {
64
+ return updateRows<TRecord>(config, input);
104
65
  },
105
-
106
- async delete<TRecord extends object = DbRecord>(
107
- input: DbDeleteInput,
108
- ): Promise<DbResult<TRecord[]>> {
109
- const validationError = validateRequiredFilters(input.filters, 'Delete');
110
-
111
- if (validationError !== null) {
112
- return validationError;
113
- }
114
-
115
- const url = buildMutationUrl(config.url, input.table, input.filters);
116
-
117
- return requestRows<TRecord>(config, url, {
118
- method: 'DELETE',
119
- });
66
+ delete<TRecord extends object = DbRecord>(input: DbDeleteInput) {
67
+ return deleteRows<TRecord>(config, input);
120
68
  },
121
69
  };
70
+ }
122
71
 
123
- if (!config.realtime || config.realtimeClient === undefined) {
124
- return baseAdapter;
125
- }
72
+ function deleteRows<TRecord extends object>(
73
+ config: NormalizedConfig,
74
+ input: DbDeleteInput,
75
+ ): Promise<DbResult<TRecord[]>> {
76
+ const validationError = validateRequiredFilters(input.filters, 'Delete');
77
+ if (validationError !== null) return Promise.resolve(validationError);
78
+ const url = buildMutationUrl(config.url, input.table, input.filters);
79
+ return requestRows<TRecord>(config, url, { method: 'DELETE' });
80
+ }
126
81
 
127
- return {
128
- ...baseAdapter,
129
- realtime: createRealtimeApi({
130
- client: config.realtimeClient,
131
- defaultSchema: config.schema,
82
+ async function findRowById<TRecord extends object>(
83
+ config: NormalizedConfig,
84
+ input: DbFindByIdInput,
85
+ ): Promise<DbResult<TRecord | null>> {
86
+ const result = await requestRows<TRecord>(
87
+ config,
88
+ buildSelectUrl(config.url, {
89
+ table: input.table,
90
+ columns: input.columns,
91
+ filters: [{ field: input.idField ?? 'id', operator: 'eq', value: input.id }],
92
+ page: { limit: 1 },
93
+ schema: input.schema,
132
94
  }),
95
+ { method: 'GET' },
96
+ );
97
+ if (!result.ok) return result;
98
+ const success: DbFindByIdSuccessResult<TRecord> = {
99
+ ok: true,
100
+ data: result.data[0] ?? null,
133
101
  };
102
+ return success;
103
+ }
104
+
105
+ function insertRows<TRecord extends object>(
106
+ config: NormalizedConfig,
107
+ input: DbInsertInput<TRecord>,
108
+ ): Promise<DbResult<TRecord[]>> {
109
+ const url = buildMutationUrl(config.url, input.table, []);
110
+ return requestRows<TRecord>(config, url, {
111
+ method: 'POST',
112
+ body: JSON.stringify(input.values),
113
+ });
134
114
  }
135
115
 
136
116
  function normalizeConfig(options: SupabaseDbAdapterOptions): NormalizedConfig {
137
117
  const fetchImplementation = options.fetch ?? globalThis.fetch;
138
-
139
118
  if (typeof fetchImplementation !== 'function') {
140
119
  throw new TypeError('A fetch implementation is required to use Supabase Database.');
141
120
  }
142
-
143
121
  return {
144
122
  url: validateUrl(options.url),
145
123
  anonKey: validateKey(options.anonKey, 'Supabase anon key'),
@@ -161,13 +139,8 @@ async function requestRows<TRecord extends object>(
161
139
  headers: createHeaders(config, init.headers),
162
140
  });
163
141
  const body = await readJsonBody(response);
164
-
165
- if (!response.ok) {
166
- return { ok: false, error: mapHttpError(response.status, body) };
167
- }
168
-
142
+ if (!response.ok) return { ok: false, error: mapHttpError(response.status, body) };
169
143
  const records = normalizeRecords<TRecord>(body);
170
-
171
144
  if (records === null) {
172
145
  return {
173
146
  ok: false,
@@ -178,17 +151,35 @@ async function requestRows<TRecord extends object>(
178
151
  ),
179
152
  };
180
153
  }
181
-
182
154
  return { ok: true, data: records };
183
155
  } catch (error) {
184
156
  if (error instanceof TypeError) {
185
157
  return { ok: false, error: createDbError('validation_error', error.message, error) };
186
158
  }
187
-
188
159
  return { ok: false, error: mapNetworkError(error) };
189
160
  }
190
161
  }
191
162
 
163
+ function selectRows<TRecord extends object>(
164
+ config: NormalizedConfig,
165
+ input: DbSelectInput,
166
+ ): Promise<DbResult<TRecord[]>> {
167
+ return requestRows<TRecord>(config, buildSelectUrl(config.url, input), { method: 'GET' });
168
+ }
169
+
170
+ function updateRows<TRecord extends object>(
171
+ config: NormalizedConfig,
172
+ input: DbUpdateInput<TRecord>,
173
+ ): Promise<DbResult<TRecord[]>> {
174
+ const validationError = validateRequiredFilters(input.filters, 'Update');
175
+ if (validationError !== null) return Promise.resolve(validationError);
176
+ const url = buildMutationUrl(config.url, input.table, input.filters);
177
+ return requestRows<TRecord>(config, url, {
178
+ method: 'PATCH',
179
+ body: JSON.stringify(input.values),
180
+ });
181
+ }
182
+
192
183
  function validateRequiredFilters(
193
184
  filters: readonly DbFilter[],
194
185
  label: string,
@@ -213,28 +204,21 @@ function createHeaders(
213
204
  existingHeaders: HeadersInit | undefined,
214
205
  ): Headers {
215
206
  const headers = new Headers(existingHeaders);
216
-
217
207
  headers.set('apikey', config.anonKey);
218
208
  headers.set('Authorization', `Bearer ${config.anonKey}`);
219
209
  headers.set('Accept', 'application/json');
220
210
  headers.set('Content-Type', 'application/json');
221
211
  headers.set('Prefer', 'return=representation');
222
-
223
212
  if (config.schema !== 'public') {
224
213
  headers.set('Accept-Profile', config.schema);
225
214
  headers.set('Content-Profile', config.schema);
226
215
  }
227
-
228
216
  return headers;
229
217
  }
230
218
 
231
219
  async function readJsonBody(response: Response): Promise<unknown> {
232
220
  const text = await response.text();
233
-
234
- if (text.trim().length === 0) {
235
- return [];
236
- }
237
-
221
+ if (text.trim().length === 0) return [];
238
222
  try {
239
223
  return JSON.parse(text);
240
224
  } catch {
@@ -243,14 +227,8 @@ async function readJsonBody(response: Response): Promise<unknown> {
243
227
  }
244
228
 
245
229
  function normalizeRecords<TRecord extends object>(value: unknown): TRecord[] | null {
246
- if (Array.isArray(value)) {
247
- return value.filter(isRecord<TRecord>);
248
- }
249
-
250
- if (isRecord<TRecord>(value)) {
251
- return [value];
252
- }
253
-
230
+ if (Array.isArray(value)) return value.filter(isRecord<TRecord>);
231
+ if (isRecord<TRecord>(value)) return [value];
254
232
  return null;
255
233
  }
256
234