@ankhorage/supabase-db 0.2.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +181 -0
  4. package/dist/adapter.d.ts +3 -0
  5. package/dist/adapter.d.ts.map +1 -0
  6. package/dist/adapter.js +160 -0
  7. package/dist/adapter.js.map +1 -0
  8. package/dist/admin.d.ts +3 -0
  9. package/dist/admin.d.ts.map +1 -0
  10. package/dist/admin.js +148 -0
  11. package/dist/admin.js.map +1 -0
  12. package/dist/errors.d.ts +5 -0
  13. package/dist/errors.d.ts.map +1 -0
  14. package/dist/errors.js +37 -0
  15. package/dist/errors.js.map +1 -0
  16. package/dist/index.d.ts +5 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +4 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/query.d.ts +4 -0
  21. package/dist/query.d.ts.map +1 -0
  22. package/dist/query.js +94 -0
  23. package/dist/query.js.map +1 -0
  24. package/dist/realtime.d.ts +10 -0
  25. package/dist/realtime.d.ts.map +1 -0
  26. package/dist/realtime.js +95 -0
  27. package/dist/realtime.js.map +1 -0
  28. package/dist/types.d.ts +117 -0
  29. package/dist/types.d.ts.map +1 -0
  30. package/dist/types.js +2 -0
  31. package/dist/types.js.map +1 -0
  32. package/dist/validation.d.ts +7 -0
  33. package/dist/validation.d.ts.map +1 -0
  34. package/dist/validation.js +43 -0
  35. package/dist/validation.js.map +1 -0
  36. package/package.json +82 -0
  37. package/src/adapter.test.ts +173 -0
  38. package/src/adapter.ts +249 -0
  39. package/src/admin.test.ts +102 -0
  40. package/src/admin.ts +205 -0
  41. package/src/errors.ts +66 -0
  42. package/src/index.ts +23 -0
  43. package/src/query.ts +122 -0
  44. package/src/realtime.test.ts +178 -0
  45. package/src/realtime.ts +145 -0
  46. package/src/types.ts +145 -0
  47. package/src/validation.ts +57 -0
@@ -0,0 +1,173 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { createSupabaseDbAdapter } from './adapter.js';
4
+
5
+ interface FetchCall {
6
+ readonly url: string;
7
+ readonly init: RequestInit | undefined;
8
+ }
9
+
10
+ interface PostRecord {
11
+ readonly id: string;
12
+ readonly title: string;
13
+ }
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');
47
+ });
48
+
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
+ });
55
+
56
+ const result = await adapter.findById<PostRecord>({ table: 'posts', id: 'post-1' });
57
+
58
+ expect(result).toEqual({ ok: true, data: { id: 'post-1', title: 'Hello' } });
59
+ });
60
+
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
+
68
+ const result = await adapter.findById<PostRecord>({ table: 'posts', id: 'missing' });
69
+
70
+ expect(result).toEqual({ ok: true, data: null });
71
+ });
72
+
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' }));
92
+ });
93
+
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');
109
+ });
110
+
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
+ });
117
+
118
+ const result = await adapter.delete<PostRecord>({ table: 'posts', filters: [] });
119
+
120
+ expect(result.ok).toBe(false);
121
+ expect(result.ok === false ? result.error.code : '').toBe('validation_error');
122
+ });
123
+
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
+ });
130
+
131
+ const result = await adapter.select<PostRecord>({ table: 'posts' });
132
+
133
+ expect(result.ok).toBe(false);
134
+ expect(result.ok === false ? result.error.code : '').toBe('permission_denied');
135
+ });
136
+
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
+ });
144
+
145
+ expect(adapter.capabilities?.supportsRealtime).toBe(false);
146
+ expect('realtime' in adapter).toBe(false);
147
+ });
148
+ });
149
+
150
+ function jsonResponse(body: unknown, status = 200): Response {
151
+ return new Response(JSON.stringify(body), {
152
+ status,
153
+ headers: { 'Content-Type': 'application/json' },
154
+ });
155
+ }
156
+
157
+ function fetchInputToString(input: RequestInfo | URL): string {
158
+ if (typeof input === 'string') {
159
+ return input;
160
+ }
161
+
162
+ if (input instanceof URL) {
163
+ return input.toString();
164
+ }
165
+
166
+ return input.url;
167
+ }
168
+
169
+ function readHeader(init: RequestInit | undefined, name: string): string | null {
170
+ const headers = new Headers(init?.headers);
171
+
172
+ return headers.get(name);
173
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,249 @@
1
+ import type {
2
+ DbAdapter,
3
+ DbDeleteInput,
4
+ DbFilter,
5
+ DbFindByIdInput,
6
+ DbInsertInput,
7
+ DbRecord,
8
+ DbResult,
9
+ DbSelectInput,
10
+ DbUpdateInput,
11
+ } from '@ankhorage/contracts/db';
12
+
13
+ import { createDbError, mapHttpError, mapNetworkError } from './errors.js';
14
+ import { buildMutationUrl, buildSelectUrl } from './query.js';
15
+ import { createRealtimeApi } from './realtime.js';
16
+ import type { SupabaseDbAdapter, SupabaseDbAdapterOptions } from './types.js';
17
+ import { validateFilters, validateKey, validateUrl } from './validation.js';
18
+
19
+ interface NormalizedConfig {
20
+ readonly url: string;
21
+ readonly anonKey: string;
22
+ readonly schema: string;
23
+ readonly fetch: typeof fetch;
24
+ readonly realtime: boolean;
25
+ readonly realtimeClient: SupabaseDbAdapterOptions['realtimeClient'];
26
+ }
27
+
28
+ export function createSupabaseDbAdapter(options: SupabaseDbAdapterOptions): SupabaseDbAdapter {
29
+ const config = normalizeConfig(options);
30
+ const baseAdapter: DbAdapter = {
31
+ capabilities: {
32
+ supportsTransactions: false,
33
+ supportsReturning: true,
34
+ supportsRealtime: config.realtime && config.realtimeClient !== undefined,
35
+ },
36
+
37
+ async select<TRecord extends object = DbRecord>(
38
+ input: DbSelectInput,
39
+ ): Promise<DbResult<TRecord[]>> {
40
+ return requestRows<TRecord>(config, buildSelectUrl(config.url, input), {
41
+ method: 'GET',
42
+ });
43
+ },
44
+
45
+ async findById<TRecord extends object = DbRecord>(
46
+ input: DbFindByIdInput,
47
+ ): Promise<DbResult<TRecord | null>> {
48
+ const result = await requestRows<TRecord>(
49
+ config,
50
+ buildSelectUrl(config.url, {
51
+ table: input.table,
52
+ columns: input.columns,
53
+ filters: [{ field: 'id', operator: 'eq', value: input.id }],
54
+ page: { limit: 1 },
55
+ }),
56
+ { method: 'GET' },
57
+ );
58
+
59
+ if (!result.ok) {
60
+ return result;
61
+ }
62
+
63
+ const records = result.data ?? [];
64
+
65
+ return { ok: true, data: records[0] ?? null };
66
+ },
67
+
68
+ async insert<TRecord extends object = DbRecord>(
69
+ input: DbInsertInput<TRecord>,
70
+ ): Promise<DbResult<TRecord[]>> {
71
+ const url = buildMutationUrl(config.url, input.table, []);
72
+
73
+ return requestRows<TRecord>(config, url, {
74
+ method: 'POST',
75
+ body: JSON.stringify(input.values),
76
+ });
77
+ },
78
+
79
+ async update<TRecord extends object = DbRecord>(
80
+ input: DbUpdateInput<TRecord>,
81
+ ): Promise<DbResult<TRecord[]>> {
82
+ const validationError = validateRequiredFilters(input.filters, 'Update');
83
+
84
+ if (validationError !== null) {
85
+ return validationError;
86
+ }
87
+
88
+ const url = buildMutationUrl(config.url, input.table, input.filters);
89
+
90
+ return requestRows<TRecord>(config, url, {
91
+ method: 'PATCH',
92
+ body: JSON.stringify(input.values),
93
+ });
94
+ },
95
+
96
+ async delete<TRecord extends object = DbRecord>(
97
+ input: DbDeleteInput,
98
+ ): Promise<DbResult<TRecord[]>> {
99
+ const validationError = validateRequiredFilters(input.filters, 'Delete');
100
+
101
+ if (validationError !== null) {
102
+ return validationError;
103
+ }
104
+
105
+ const url = buildMutationUrl(config.url, input.table, input.filters);
106
+
107
+ return requestRows<TRecord>(config, url, {
108
+ method: 'DELETE',
109
+ });
110
+ },
111
+ };
112
+
113
+ if (!config.realtime || config.realtimeClient === undefined) {
114
+ return baseAdapter;
115
+ }
116
+
117
+ return {
118
+ ...baseAdapter,
119
+ realtime: createRealtimeApi({
120
+ client: config.realtimeClient,
121
+ defaultSchema: config.schema,
122
+ }),
123
+ };
124
+ }
125
+
126
+ function normalizeConfig(options: SupabaseDbAdapterOptions): NormalizedConfig {
127
+ const fetchImplementation = options.fetch ?? globalThis.fetch;
128
+
129
+ if (typeof fetchImplementation !== 'function') {
130
+ throw new TypeError('A fetch implementation is required to use Supabase Database.');
131
+ }
132
+
133
+ return {
134
+ url: validateUrl(options.url),
135
+ anonKey: validateKey(options.anonKey, 'Supabase anon key'),
136
+ schema: options.schema ?? 'public',
137
+ fetch: fetchImplementation,
138
+ realtime: options.realtime ?? false,
139
+ realtimeClient: options.realtimeClient,
140
+ };
141
+ }
142
+
143
+ async function requestRows<TRecord extends object>(
144
+ config: NormalizedConfig,
145
+ url: URL,
146
+ init: RequestInit,
147
+ ): Promise<DbResult<TRecord[]>> {
148
+ try {
149
+ const response = await config.fetch(url, {
150
+ ...init,
151
+ headers: createHeaders(config, init.headers),
152
+ });
153
+ const body = await readJsonBody(response);
154
+
155
+ if (!response.ok) {
156
+ return { ok: false, error: mapHttpError(response.status, body) };
157
+ }
158
+
159
+ const records = normalizeRecords<TRecord>(body);
160
+
161
+ if (records === null) {
162
+ return {
163
+ ok: false,
164
+ error: createDbError(
165
+ 'provider_error',
166
+ 'Supabase Database returned an invalid records response.',
167
+ body,
168
+ ),
169
+ };
170
+ }
171
+
172
+ return { ok: true, data: records };
173
+ } catch (error) {
174
+ if (error instanceof TypeError) {
175
+ return { ok: false, error: createDbError('validation_error', error.message, error) };
176
+ }
177
+
178
+ return { ok: false, error: mapNetworkError(error) };
179
+ }
180
+ }
181
+
182
+ function validateRequiredFilters(
183
+ filters: readonly DbFilter[],
184
+ label: string,
185
+ ): DbResult<never> | null {
186
+ try {
187
+ validateFilters(filters, label);
188
+ return null;
189
+ } catch (error) {
190
+ return {
191
+ ok: false,
192
+ error: createDbError(
193
+ 'validation_error',
194
+ error instanceof Error ? error.message : `${label} filters are invalid.`,
195
+ error,
196
+ ),
197
+ };
198
+ }
199
+ }
200
+
201
+ function createHeaders(
202
+ config: NormalizedConfig,
203
+ existingHeaders: HeadersInit | undefined,
204
+ ): Headers {
205
+ const headers = new Headers(existingHeaders);
206
+
207
+ headers.set('apikey', config.anonKey);
208
+ headers.set('Authorization', `Bearer ${config.anonKey}`);
209
+ headers.set('Accept', 'application/json');
210
+ headers.set('Content-Type', 'application/json');
211
+ headers.set('Prefer', 'return=representation');
212
+
213
+ if (config.schema !== 'public') {
214
+ headers.set('Accept-Profile', config.schema);
215
+ headers.set('Content-Profile', config.schema);
216
+ }
217
+
218
+ return headers;
219
+ }
220
+
221
+ async function readJsonBody(response: Response): Promise<unknown> {
222
+ const text = await response.text();
223
+
224
+ if (text.trim().length === 0) {
225
+ return [];
226
+ }
227
+
228
+ try {
229
+ return JSON.parse(text);
230
+ } catch {
231
+ return text;
232
+ }
233
+ }
234
+
235
+ function normalizeRecords<TRecord extends object>(value: unknown): TRecord[] | null {
236
+ if (Array.isArray(value)) {
237
+ return value.filter(isRecord<TRecord>);
238
+ }
239
+
240
+ if (isRecord<TRecord>(value)) {
241
+ return [value];
242
+ }
243
+
244
+ return null;
245
+ }
246
+
247
+ function isRecord<TRecord extends object>(value: unknown): value is TRecord {
248
+ return typeof value === 'object' && value !== null;
249
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { createSupabaseDbAdminAdapter } from './admin.js';
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(result.ok).toBe(true);
22
+ expect(result.ok ? result.executed : true).toBe(false);
23
+ expect(result.ok ? result.sql : '').toContain('create table if not exists "public"."posts"');
24
+ expect(result.ok ? result.sql : '').toContain(
25
+ '"id" uuid primary key default gen_random_uuid()',
26
+ );
27
+ expect(result.ok ? result.sql : '').toContain('"title" text not null');
28
+ expect(result.ok ? result.sql : '').toContain('"like_count" double precision default 0');
29
+ expect(result.ok ? result.sql : '').toContain('"published" boolean default false');
30
+ });
31
+
32
+ test('executes generated SQL only when explicitly configured', async () => {
33
+ const executedSql: string[] = [];
34
+ const adapter = createSupabaseDbAdminAdapter({
35
+ url: 'https://example.supabase.co',
36
+ serviceRoleKey: 'service-role',
37
+ execute: true,
38
+ executeSql: (sql) => {
39
+ executedSql.push(sql);
40
+ return Promise.resolve({ ok: true });
41
+ },
42
+ });
43
+
44
+ const result = await adapter.createCollection({
45
+ name: 'posts',
46
+ fields: [{ name: 'title', type: 'text' }],
47
+ });
48
+
49
+ expect(result).toEqual({
50
+ ok: true,
51
+ sql: executedSql[0],
52
+ executed: true,
53
+ });
54
+ expect(executedSql).toHaveLength(1);
55
+ });
56
+
57
+ test('refuses execution without a service role key', async () => {
58
+ const adapter = createSupabaseDbAdminAdapter({
59
+ url: 'https://example.supabase.co',
60
+ execute: true,
61
+ executeSql: () => Promise.resolve({ ok: true }),
62
+ });
63
+
64
+ const result = await adapter.createCollection({
65
+ name: 'posts',
66
+ fields: [{ name: 'title', type: 'text' }],
67
+ });
68
+
69
+ expect(result.ok).toBe(false);
70
+ expect(result.ok === false ? result.error.code : '').toBe('missing_service_role_key');
71
+ });
72
+
73
+ test('generates delete collection SQL', () => {
74
+ const adapter = createSupabaseDbAdminAdapter({
75
+ url: 'https://example.supabase.co',
76
+ serviceRoleKey: 'service-role',
77
+ });
78
+
79
+ const result = adapter.generateDeleteCollectionSql({ name: 'posts' });
80
+
81
+ expect(result).toEqual({
82
+ ok: true,
83
+ sql: 'drop table if exists "public"."posts";',
84
+ executed: false,
85
+ });
86
+ });
87
+
88
+ test('rejects invalid collection names', () => {
89
+ const adapter = createSupabaseDbAdminAdapter({
90
+ url: 'https://example.supabase.co',
91
+ serviceRoleKey: 'service-role',
92
+ });
93
+
94
+ const result = adapter.generateCreateCollectionSql({
95
+ name: 'invalid-name',
96
+ fields: [{ name: 'title', type: 'text' }],
97
+ });
98
+
99
+ expect(result.ok).toBe(false);
100
+ expect(result.ok === false ? result.error.code : '').toBe('validation_error');
101
+ });
102
+ });