@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,145 @@
1
+ import type { DbRecord } from '@ankhorage/contracts/db';
2
+
3
+ import type {
4
+ DbChangeEvent,
5
+ DbChangeKind,
6
+ DbChangeListener,
7
+ DbCollectionSubscriptionInput,
8
+ DbRecordSubscriptionInput,
9
+ DbSubscription,
10
+ RealtimeDbAdapter,
11
+ SupabaseRealtimeClient,
12
+ SupabaseRealtimePayload,
13
+ } from './types.js';
14
+ import { validateIdentifier } from './validation.js';
15
+
16
+ interface RealtimeApiConfig {
17
+ readonly client: SupabaseRealtimeClient;
18
+ readonly defaultSchema: string;
19
+ }
20
+
21
+ export function createRealtimeApi(config: RealtimeApiConfig): RealtimeDbAdapter['realtime'] {
22
+ return {
23
+ subscribeToCollection<TRecord extends object = DbRecord>(
24
+ input: DbCollectionSubscriptionInput,
25
+ listener: DbChangeListener<TRecord>,
26
+ ): DbSubscription {
27
+ const schema = input.schema ?? config.defaultSchema;
28
+ const table = validateIdentifier(input.table, 'Realtime table');
29
+ const channel = config.client
30
+ .channel(`ankhorage-db:${schema}:${table}`)
31
+ .on(
32
+ 'postgres_changes',
33
+ {
34
+ event: '*',
35
+ schema,
36
+ table,
37
+ },
38
+ (payload) => {
39
+ const event = normalizeRealtimeEvent<TRecord>(payload, table, schema);
40
+
41
+ if (event !== null) {
42
+ listener(event);
43
+ }
44
+ },
45
+ )
46
+ .subscribe();
47
+
48
+ return {
49
+ async unsubscribe(): Promise<void> {
50
+ await config.client.removeChannel(channel);
51
+ },
52
+ };
53
+ },
54
+
55
+ subscribeToRecord<TRecord extends object = DbRecord>(
56
+ input: DbRecordSubscriptionInput,
57
+ listener: DbChangeListener<TRecord>,
58
+ ): DbSubscription {
59
+ const schema = input.schema ?? config.defaultSchema;
60
+ const table = validateIdentifier(input.table, 'Realtime table');
61
+ const idField = validateIdentifier(input.idField ?? 'id', 'Realtime record id field');
62
+ const channel = config.client
63
+ .channel(`ankhorage-db:${schema}:${table}:${idField}:${String(input.id)}`)
64
+ .on(
65
+ 'postgres_changes',
66
+ {
67
+ event: '*',
68
+ schema,
69
+ table,
70
+ filter: `${idField}=eq.${String(input.id)}`,
71
+ },
72
+ (payload) => {
73
+ const event = normalizeRealtimeEvent<TRecord>(payload, table, schema);
74
+
75
+ if (event !== null) {
76
+ listener(event);
77
+ }
78
+ },
79
+ )
80
+ .subscribe();
81
+
82
+ return {
83
+ async unsubscribe(): Promise<void> {
84
+ await config.client.removeChannel(channel);
85
+ },
86
+ };
87
+ },
88
+ };
89
+ }
90
+
91
+ export function normalizeRealtimeEvent<TRecord extends object = DbRecord>(
92
+ payload: SupabaseRealtimePayload,
93
+ fallbackTable: string,
94
+ fallbackSchema: string,
95
+ ): DbChangeEvent<TRecord> | null {
96
+ const kind = normalizeKind(payload.eventType);
97
+
98
+ if (kind === null) {
99
+ return null;
100
+ }
101
+
102
+ const record = normalizeRecord<TRecord>(payload.new);
103
+ const previousRecord = normalizeRecord<TRecord>(payload.old);
104
+ const base = {
105
+ table: payload.table ?? fallbackTable,
106
+ schema: payload.schema ?? fallbackSchema,
107
+ kind,
108
+ record: kind === 'delete' ? null : record,
109
+ committedAt: payload.commit_timestamp,
110
+ };
111
+
112
+ if (previousRecord === null) {
113
+ return base;
114
+ }
115
+
116
+ return {
117
+ ...base,
118
+ previousRecord,
119
+ };
120
+ }
121
+
122
+ function normalizeKind(value: string | undefined): DbChangeKind | null {
123
+ switch (value) {
124
+ case 'INSERT':
125
+ return 'insert';
126
+ case 'UPDATE':
127
+ return 'update';
128
+ case 'DELETE':
129
+ return 'delete';
130
+ default:
131
+ return null;
132
+ }
133
+ }
134
+
135
+ function normalizeRecord<TRecord extends object>(value: unknown): TRecord | null {
136
+ if (!isRecord<TRecord>(value)) {
137
+ return null;
138
+ }
139
+
140
+ return value;
141
+ }
142
+
143
+ function isRecord<TRecord extends object>(value: unknown): value is TRecord {
144
+ return typeof value === 'object' && value !== null;
145
+ }
package/src/types.ts ADDED
@@ -0,0 +1,145 @@
1
+ import type { DbAdapter, DbRecord } from '@ankhorage/contracts/db';
2
+
3
+ interface SupabaseRealtimePostgresChangesFilter {
4
+ readonly event: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
5
+ readonly schema: string;
6
+ readonly table: string;
7
+ readonly filter?: string;
8
+ }
9
+
10
+ export interface SupabaseRealtimeChannel {
11
+ on(
12
+ type: 'postgres_changes',
13
+ filter: SupabaseRealtimePostgresChangesFilter,
14
+ callback: (payload: SupabaseRealtimePayload) => void,
15
+ ): SupabaseRealtimeChannel;
16
+ subscribe(callback?: (status: string) => void): SupabaseRealtimeChannel;
17
+ unsubscribe(): Promise<'ok' | 'timed out' | 'error'>;
18
+ }
19
+
20
+ export interface SupabaseRealtimeClient {
21
+ channel(topic: string): SupabaseRealtimeChannel;
22
+ removeChannel(channel: SupabaseRealtimeChannel): Promise<'ok' | 'timed out' | 'error'>;
23
+ }
24
+
25
+ export interface SupabaseRealtimePayload {
26
+ eventType?: string;
27
+ schema?: string;
28
+ table?: string;
29
+ commit_timestamp?: string;
30
+ new?: unknown;
31
+ old?: unknown;
32
+ errors?: string[] | null;
33
+ }
34
+
35
+ export interface SupabaseDbAdapterOptions {
36
+ readonly url: string;
37
+ readonly anonKey: string;
38
+ readonly schema?: string;
39
+ readonly fetch?: typeof fetch;
40
+ readonly realtime?: boolean;
41
+ readonly realtimeClient?: SupabaseRealtimeClient;
42
+ }
43
+
44
+ export interface SupabaseDbAdminAdapterOptions {
45
+ readonly url: string;
46
+ readonly serviceRoleKey?: string;
47
+ readonly schema?: string;
48
+ readonly execute?: boolean;
49
+ readonly executeSql?: (sql: string) => Promise<DbAdminSqlExecutionResult>;
50
+ }
51
+
52
+ export interface DbAdminSqlExecutionResult {
53
+ readonly ok: boolean;
54
+ readonly error?: {
55
+ readonly code: string;
56
+ readonly message: string;
57
+ readonly cause?: unknown;
58
+ };
59
+ }
60
+
61
+ export type SupabaseDbAdapter = DbAdapter & Partial<RealtimeDbAdapter>;
62
+
63
+ export interface RealtimeDbAdapter {
64
+ readonly realtime: {
65
+ subscribeToCollection<TRecord extends object = DbRecord>(
66
+ input: DbCollectionSubscriptionInput,
67
+ listener: DbChangeListener<TRecord>,
68
+ ): DbSubscription;
69
+ subscribeToRecord<TRecord extends object = DbRecord>(
70
+ input: DbRecordSubscriptionInput,
71
+ listener: DbChangeListener<TRecord>,
72
+ ): DbSubscription;
73
+ };
74
+ }
75
+
76
+ export interface DbCollectionSubscriptionInput {
77
+ readonly table: string;
78
+ readonly schema?: string;
79
+ }
80
+
81
+ export interface DbRecordSubscriptionInput extends DbCollectionSubscriptionInput {
82
+ readonly id: string | number;
83
+ readonly idField?: string;
84
+ }
85
+
86
+ export type DbChangeKind = 'insert' | 'update' | 'delete';
87
+
88
+ export interface DbChangeEvent<TRecord extends object = DbRecord> {
89
+ readonly table: string;
90
+ readonly schema: string;
91
+ readonly kind: DbChangeKind;
92
+ readonly record: TRecord | null;
93
+ readonly previousRecord?: TRecord;
94
+ readonly committedAt?: string;
95
+ }
96
+
97
+ export type DbChangeListener<TRecord extends object = DbRecord> = (
98
+ event: DbChangeEvent<TRecord>,
99
+ ) => void;
100
+
101
+ export interface DbSubscription {
102
+ unsubscribe(): Promise<void>;
103
+ }
104
+
105
+ export interface SupabaseCollectionDefinition {
106
+ readonly name: string;
107
+ readonly fields: readonly SupabaseFieldDefinition[];
108
+ readonly primaryKey?: string;
109
+ }
110
+
111
+ export type SupabaseFieldType = 'text' | 'number' | 'boolean' | 'datetime' | 'json' | 'uuid';
112
+
113
+ export interface SupabaseFieldDefinition {
114
+ readonly name: string;
115
+ readonly type: SupabaseFieldType;
116
+ readonly required?: boolean;
117
+ readonly unique?: boolean;
118
+ readonly defaultValue?: string | number | boolean | null;
119
+ }
120
+
121
+ export interface SupabaseDbAdminAdapter {
122
+ readonly capabilities: {
123
+ readonly supportsSchemaGeneration: true;
124
+ readonly supportsDirectExecution: boolean;
125
+ };
126
+ createCollection(input: SupabaseCollectionDefinition): Promise<DbAdminResult>;
127
+ deleteCollection(input: { readonly name: string }): Promise<DbAdminResult>;
128
+ generateCreateCollectionSql(input: SupabaseCollectionDefinition): DbAdminResult;
129
+ generateDeleteCollectionSql(input: { readonly name: string }): DbAdminResult;
130
+ }
131
+
132
+ export type DbAdminResult =
133
+ | {
134
+ readonly ok: true;
135
+ readonly sql: string;
136
+ readonly executed: boolean;
137
+ }
138
+ | {
139
+ readonly ok: false;
140
+ readonly error: {
141
+ readonly code: string;
142
+ readonly message: string;
143
+ readonly cause?: unknown;
144
+ };
145
+ };
@@ -0,0 +1,57 @@
1
+ import type { DbFilter } from '@ankhorage/contracts/db';
2
+
3
+ export function validateUrl(url: string): string {
4
+ const trimmed = url.trim();
5
+
6
+ if (trimmed.length === 0) {
7
+ throw new TypeError('Supabase URL is required.');
8
+ }
9
+
10
+ try {
11
+ new URL(trimmed);
12
+ } catch {
13
+ throw new TypeError('Supabase URL must be a valid URL.');
14
+ }
15
+
16
+ return trimmed.replace(/\/+$/, '');
17
+ }
18
+
19
+ export function validateKey(value: string, label: string): string {
20
+ const trimmed = value.trim();
21
+
22
+ if (trimmed.length === 0) {
23
+ throw new TypeError(`${label} is required.`);
24
+ }
25
+
26
+ return trimmed;
27
+ }
28
+
29
+ export function validateIdentifier(value: string, label: string): string {
30
+ const trimmed = value.trim();
31
+
32
+ if (trimmed.length === 0) {
33
+ throw new TypeError(`${label} is required.`);
34
+ }
35
+
36
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) {
37
+ throw new TypeError(`${label} must be a valid SQL identifier.`);
38
+ }
39
+
40
+ return trimmed;
41
+ }
42
+
43
+ export function validateFilters(filters: readonly DbFilter[], label: string): void {
44
+ if (filters.length === 0) {
45
+ throw new TypeError(`${label} requires at least one filter.`);
46
+ }
47
+
48
+ for (const filter of filters) {
49
+ validateIdentifier(filter.field, 'Filter field');
50
+ }
51
+ }
52
+
53
+ export function quoteIdentifier(value: string): string {
54
+ const identifier = validateIdentifier(value, 'SQL identifier');
55
+
56
+ return `"${identifier.replaceAll('"', '""')}"`;
57
+ }