@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
package/src/admin.ts ADDED
@@ -0,0 +1,205 @@
1
+ import type {
2
+ DbAdminResult,
3
+ SupabaseCollectionDefinition,
4
+ SupabaseDbAdminAdapter,
5
+ SupabaseDbAdminAdapterOptions,
6
+ SupabaseFieldDefinition,
7
+ } from './types.js';
8
+ import { quoteIdentifier, validateKey, validateUrl } from './validation.js';
9
+
10
+ interface NormalizedAdminConfig {
11
+ readonly url: string;
12
+ readonly schema: string;
13
+ readonly serviceRoleKey?: string;
14
+ readonly execute: boolean;
15
+ readonly executeSql?: SupabaseDbAdminAdapterOptions['executeSql'];
16
+ }
17
+
18
+ export function createSupabaseDbAdminAdapter(
19
+ options: SupabaseDbAdminAdapterOptions,
20
+ ): SupabaseDbAdminAdapter {
21
+ const config = normalizeAdminConfig(options);
22
+
23
+ return {
24
+ capabilities: {
25
+ supportsSchemaGeneration: true,
26
+ supportsDirectExecution: config.execute && config.executeSql !== undefined,
27
+ },
28
+
29
+ async createCollection(input: SupabaseCollectionDefinition): Promise<DbAdminResult> {
30
+ const generated = generateCreateCollectionSql(input, config.schema);
31
+
32
+ if (!generated.ok || !config.execute) {
33
+ return generated;
34
+ }
35
+
36
+ return executeSql(config, generated.sql);
37
+ },
38
+
39
+ async deleteCollection(input: { readonly name: string }): Promise<DbAdminResult> {
40
+ const generated = generateDeleteCollectionSql(input, config.schema);
41
+
42
+ if (!generated.ok || !config.execute) {
43
+ return generated;
44
+ }
45
+
46
+ return executeSql(config, generated.sql);
47
+ },
48
+
49
+ generateCreateCollectionSql(input: SupabaseCollectionDefinition): DbAdminResult {
50
+ return generateCreateCollectionSql(input, config.schema);
51
+ },
52
+
53
+ generateDeleteCollectionSql(input: { readonly name: string }): DbAdminResult {
54
+ return generateDeleteCollectionSql(input, config.schema);
55
+ },
56
+ };
57
+ }
58
+
59
+ function normalizeAdminConfig(options: SupabaseDbAdminAdapterOptions): NormalizedAdminConfig {
60
+ const normalized: NormalizedAdminConfig = {
61
+ url: validateUrl(options.url),
62
+ schema: options.schema ?? 'public',
63
+ execute: options.execute ?? false,
64
+ executeSql: options.executeSql,
65
+ };
66
+
67
+ if (options.serviceRoleKey === undefined) {
68
+ return normalized;
69
+ }
70
+
71
+ return {
72
+ ...normalized,
73
+ serviceRoleKey: validateKey(options.serviceRoleKey, 'Supabase service role key'),
74
+ };
75
+ }
76
+
77
+ function generateCreateCollectionSql(
78
+ input: SupabaseCollectionDefinition,
79
+ schemaValue: string,
80
+ ): DbAdminResult {
81
+ try {
82
+ const schema = quoteIdentifier(schemaValue);
83
+ const table = quoteIdentifier(input.name);
84
+ const primaryKey = input.primaryKey ?? 'id';
85
+ const fields = input.fields.map((field) => formatField(field));
86
+ const primaryKeyFieldExists = input.fields.some((field) => field.name === primaryKey);
87
+ const columns = primaryKeyFieldExists
88
+ ? fields
89
+ : [`${quoteIdentifier(primaryKey)} uuid primary key default gen_random_uuid()`, ...fields];
90
+
91
+ if (columns.length === 0) {
92
+ return createAdminError('validation_error', 'A collection requires at least one field.');
93
+ }
94
+
95
+ return {
96
+ ok: true,
97
+ sql: `create schema if not exists ${schema};\ncreate table if not exists ${schema}.${table} (\n ${columns.join(',\n ')}\n);`,
98
+ executed: false,
99
+ };
100
+ } catch (error) {
101
+ return createAdminError('validation_error', 'Invalid collection definition.', error);
102
+ }
103
+ }
104
+
105
+ function generateDeleteCollectionSql(
106
+ input: { readonly name: string },
107
+ schemaValue: string,
108
+ ): DbAdminResult {
109
+ try {
110
+ const schema = quoteIdentifier(schemaValue);
111
+ const table = quoteIdentifier(input.name);
112
+
113
+ return {
114
+ ok: true,
115
+ sql: `drop table if exists ${schema}.${table};`,
116
+ executed: false,
117
+ };
118
+ } catch (error) {
119
+ return createAdminError('validation_error', 'Invalid collection name.', error);
120
+ }
121
+ }
122
+
123
+ async function executeSql(config: NormalizedAdminConfig, sql: string): Promise<DbAdminResult> {
124
+ if (config.executeSql === undefined) {
125
+ return createAdminError(
126
+ 'execution_not_configured',
127
+ 'Schema execution requires an executeSql callback.',
128
+ );
129
+ }
130
+
131
+ if (config.serviceRoleKey === undefined) {
132
+ return createAdminError(
133
+ 'missing_service_role_key',
134
+ 'Schema execution requires a service role key in privileged environments only.',
135
+ );
136
+ }
137
+
138
+ const result = await config.executeSql(sql);
139
+
140
+ if (!result.ok) {
141
+ return {
142
+ ok: false,
143
+ error: result.error ?? {
144
+ code: 'provider_error',
145
+ message: 'Supabase schema execution failed.',
146
+ },
147
+ };
148
+ }
149
+
150
+ return {
151
+ ok: true,
152
+ sql,
153
+ executed: true,
154
+ };
155
+ }
156
+
157
+ function formatField(field: SupabaseFieldDefinition): string {
158
+ const name = quoteIdentifier(field.name);
159
+ const type = mapFieldType(field.type);
160
+ const required = field.required === true ? ' not null' : '';
161
+ const unique = field.unique === true ? ' unique' : '';
162
+ const defaultValue = formatDefaultValue(field.defaultValue);
163
+
164
+ return `${name} ${type}${required}${unique}${defaultValue}`;
165
+ }
166
+
167
+ function mapFieldType(type: SupabaseFieldDefinition['type']): string {
168
+ switch (type) {
169
+ case 'text':
170
+ return 'text';
171
+ case 'number':
172
+ return 'double precision';
173
+ case 'boolean':
174
+ return 'boolean';
175
+ case 'datetime':
176
+ return 'timestamptz';
177
+ case 'json':
178
+ return 'jsonb';
179
+ case 'uuid':
180
+ return 'uuid';
181
+ }
182
+ }
183
+
184
+ function formatDefaultValue(value: SupabaseFieldDefinition['defaultValue']): string {
185
+ if (value === undefined) {
186
+ return '';
187
+ }
188
+
189
+ if (value === null) {
190
+ return ' default null';
191
+ }
192
+
193
+ if (typeof value === 'string') {
194
+ return ` default '${value.replaceAll("'", "''")}'`;
195
+ }
196
+
197
+ return ` default ${String(value)}`;
198
+ }
199
+
200
+ function createAdminError(code: string, message: string, cause?: unknown): DbAdminResult {
201
+ return {
202
+ ok: false,
203
+ error: cause === undefined ? { code, message } : { code, message, cause },
204
+ };
205
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { DbAdapterError } from '@ankhorage/contracts/db';
2
+
3
+ export function createDbError(code: string, message: string, cause?: unknown): DbAdapterError {
4
+ return cause === undefined ? { code, message } : { code, message, cause };
5
+ }
6
+
7
+ export function mapNetworkError(error: unknown): DbAdapterError {
8
+ return createDbError('network_error', 'Supabase Database request failed.', error);
9
+ }
10
+
11
+ export function mapHttpError(status: number, body: unknown): DbAdapterError {
12
+ const providerMessage = readProviderMessage(body);
13
+
14
+ if (status === 401 || status === 403) {
15
+ return createDbError(
16
+ 'permission_denied',
17
+ providerMessage ?? 'Supabase Database denied the request.',
18
+ body,
19
+ );
20
+ }
21
+
22
+ if (status === 404) {
23
+ return createDbError(
24
+ 'missing_table',
25
+ providerMessage ?? 'Supabase Database resource was not found.',
26
+ body,
27
+ );
28
+ }
29
+
30
+ if (status >= 400 && status < 500) {
31
+ return createDbError(
32
+ 'invalid_query',
33
+ providerMessage ?? 'Supabase Database rejected the query.',
34
+ body,
35
+ );
36
+ }
37
+
38
+ return createDbError(
39
+ 'provider_error',
40
+ providerMessage ?? 'Supabase Database returned an unexpected error.',
41
+ body,
42
+ );
43
+ }
44
+
45
+ function readProviderMessage(body: unknown): string | undefined {
46
+ if (!isRecord(body)) {
47
+ return undefined;
48
+ }
49
+
50
+ const { message } = body;
51
+ const { error } = body;
52
+
53
+ if (typeof message === 'string' && message.trim().length > 0) {
54
+ return message;
55
+ }
56
+
57
+ if (typeof error === 'string' && error.trim().length > 0) {
58
+ return error;
59
+ }
60
+
61
+ return undefined;
62
+ }
63
+
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === 'object' && value !== null;
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export { createSupabaseDbAdapter } from './adapter.js';
2
+ export { createSupabaseDbAdminAdapter } from './admin.js';
3
+ export { normalizeRealtimeEvent } from './realtime.js';
4
+ export type {
5
+ DbAdminResult,
6
+ DbAdminSqlExecutionResult,
7
+ DbChangeEvent,
8
+ DbChangeKind,
9
+ DbChangeListener,
10
+ DbCollectionSubscriptionInput,
11
+ DbRecordSubscriptionInput,
12
+ DbSubscription,
13
+ RealtimeDbAdapter,
14
+ SupabaseCollectionDefinition,
15
+ SupabaseDbAdapter,
16
+ SupabaseDbAdapterOptions,
17
+ SupabaseDbAdminAdapter,
18
+ SupabaseDbAdminAdapterOptions,
19
+ SupabaseFieldDefinition,
20
+ SupabaseFieldType,
21
+ SupabaseRealtimeClient,
22
+ SupabaseRealtimePayload,
23
+ } from './types.js';
package/src/query.ts ADDED
@@ -0,0 +1,122 @@
1
+ import type { DbFilter, DbPage, DbSelectInput, DbSort } from '@ankhorage/contracts/db';
2
+
3
+ import { validateIdentifier } from './validation.js';
4
+
5
+ export function buildSelectUrl(baseUrl: string, input: DbSelectInput): URL {
6
+ const table = validateIdentifier(input.table, 'Table');
7
+ const url = new URL(`${baseUrl}/rest/v1/${table}`);
8
+
9
+ url.searchParams.set('select', formatColumns(input.columns));
10
+
11
+ applyFilters(url, input.filters ?? []);
12
+ applySort(url, input.sort ?? []);
13
+ applyPage(url, input.page);
14
+
15
+ return url;
16
+ }
17
+
18
+ export function buildMutationUrl(
19
+ baseUrl: string,
20
+ tableValue: string,
21
+ filters: readonly DbFilter[],
22
+ ): URL {
23
+ const table = validateIdentifier(tableValue, 'Table');
24
+ const url = new URL(`${baseUrl}/rest/v1/${table}`);
25
+
26
+ applyFilters(url, filters);
27
+
28
+ return url;
29
+ }
30
+
31
+ function applyFilters(url: URL, filters: readonly DbFilter[]): void {
32
+ for (const filter of filters) {
33
+ const field = validateIdentifier(filter.field, 'Filter field');
34
+ url.searchParams.append(field, formatFilter(filter));
35
+ }
36
+ }
37
+
38
+ function applySort(url: URL, sort: readonly DbSort[]): void {
39
+ if (sort.length === 0) {
40
+ return;
41
+ }
42
+
43
+ const order = sort
44
+ .map((item) => {
45
+ const field = validateIdentifier(item.field, 'Sort field');
46
+ return `${field}.${item.direction ?? 'asc'}`;
47
+ })
48
+ .join(',');
49
+
50
+ url.searchParams.set('order', order);
51
+ }
52
+
53
+ function applyPage(url: URL, page: DbPage | undefined): void {
54
+ if (page?.limit !== undefined) {
55
+ url.searchParams.set('limit', String(page.limit));
56
+ }
57
+
58
+ if (page?.offset !== undefined) {
59
+ url.searchParams.set('offset', String(page.offset));
60
+ }
61
+ }
62
+
63
+ function formatColumns(columns: readonly string[] | undefined): string {
64
+ if (columns === undefined || columns.length === 0) {
65
+ return '*';
66
+ }
67
+
68
+ return columns.map((column) => validateIdentifier(column, 'Column')).join(',');
69
+ }
70
+
71
+ function formatFilter(filter: DbFilter): string {
72
+ switch (filter.operator) {
73
+ case 'eq':
74
+ return `eq.${formatValue(filter.value)}`;
75
+ case 'neq':
76
+ return `neq.${formatValue(filter.value)}`;
77
+ case 'gt':
78
+ return `gt.${formatValue(filter.value)}`;
79
+ case 'gte':
80
+ return `gte.${formatValue(filter.value)}`;
81
+ case 'lt':
82
+ return `lt.${formatValue(filter.value)}`;
83
+ case 'lte':
84
+ return `lte.${formatValue(filter.value)}`;
85
+ case 'in':
86
+ return `in.(${formatInValue(filter.value)})`;
87
+ case 'contains':
88
+ return `cs.${formatValue(filter.value)}`;
89
+ case 'startsWith':
90
+ return `like.${formatLikeValue(filter.value)}*`;
91
+ case 'endsWith':
92
+ return `like.*${formatLikeValue(filter.value)}`;
93
+ }
94
+ }
95
+
96
+ function formatInValue(value: unknown): string {
97
+ if (!Array.isArray(value)) {
98
+ return formatValue(value);
99
+ }
100
+
101
+ return value.map((item) => formatValue(item)).join(',');
102
+ }
103
+
104
+ function formatLikeValue(value: unknown): string {
105
+ return String(value).replaceAll('*', '\\*');
106
+ }
107
+
108
+ function formatValue(value: unknown): string {
109
+ if (value === null) {
110
+ return 'null';
111
+ }
112
+
113
+ if (typeof value === 'string') {
114
+ return value;
115
+ }
116
+
117
+ if (typeof value === 'number' || typeof value === 'boolean') {
118
+ return String(value);
119
+ }
120
+
121
+ return JSON.stringify(value);
122
+ }
@@ -0,0 +1,178 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { createSupabaseDbAdapter } from './adapter.js';
4
+ import { normalizeRealtimeEvent } from './realtime.js';
5
+ import type {
6
+ SupabaseRealtimeChannel,
7
+ SupabaseRealtimeClient,
8
+ SupabaseRealtimePayload,
9
+ } from './types.js';
10
+
11
+ interface PostRecord {
12
+ readonly id: string;
13
+ readonly title: string;
14
+ }
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',
32
+ schema: 'public',
33
+ kind: 'insert',
34
+ record: { id: 'post-1', title: 'Hello' },
35
+ committedAt: '2026-05-12T10:00:00Z',
36
+ });
37
+ });
38
+
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
+ });
58
+ });
59
+
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
+ });
78
+ });
79
+
80
+ test('ignores unknown realtime events', () => {
81
+ const event = normalizeRealtimeEvent<PostRecord>({ eventType: 'TRUNCATE' }, 'posts', 'public');
82
+
83
+ expect(event).toBeNull();
84
+ });
85
+ });
86
+
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();
113
+
114
+ expect(received).toHaveLength(1);
115
+ expect(client.removedChannels).toBe(1);
116
+ });
117
+
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');
134
+ });
135
+ });
136
+
137
+ function createFakeRealtimeClient(): SupabaseRealtimeClient & {
138
+ emit(payload: SupabaseRealtimePayload): void;
139
+ readonly removedChannels: number;
140
+ readonly lastFilter: { readonly filter?: string } | undefined;
141
+ } {
142
+ let callback: ((payload: SupabaseRealtimePayload) => void) | undefined;
143
+ let removedChannels = 0;
144
+ let lastFilter: { readonly filter?: string } | undefined;
145
+
146
+ const channel: SupabaseRealtimeChannel = {
147
+ on(_type, filter, nextCallback) {
148
+ lastFilter = { filter: filter.filter };
149
+ callback = nextCallback;
150
+ return channel;
151
+ },
152
+ subscribe() {
153
+ return channel;
154
+ },
155
+ unsubscribe() {
156
+ return Promise.resolve<'ok'>('ok');
157
+ },
158
+ };
159
+
160
+ return {
161
+ channel() {
162
+ return channel;
163
+ },
164
+ removeChannel() {
165
+ removedChannels += 1;
166
+ return Promise.resolve<'ok'>('ok');
167
+ },
168
+ emit(payload: SupabaseRealtimePayload) {
169
+ callback?.(payload);
170
+ },
171
+ get removedChannels() {
172
+ return removedChannels;
173
+ },
174
+ get lastFilter() {
175
+ return lastFilter;
176
+ },
177
+ };
178
+ }