@unieojs/unio-supabase-shim 0.1.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.
@@ -0,0 +1,257 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+
3
+ import type {
4
+ QueryFilter,
5
+ SelectQuery,
6
+ ShimRow,
7
+ ShimValue,
8
+ SupabaseShimDatabase,
9
+ } from "./types.js";
10
+
11
+ interface PGliteSnapshot {
12
+ kind: "pglite-data-dir";
13
+ compression: "gzip";
14
+ type: string;
15
+ data: string;
16
+ }
17
+
18
+ export class PGliteShimDatabase implements SupabaseShimDatabase {
19
+ #database: PGlite;
20
+
21
+ private constructor(database: PGlite) {
22
+ this.#database = database;
23
+ }
24
+
25
+ static async create(): Promise<PGliteShimDatabase> {
26
+ return new PGliteShimDatabase(await PGlite.create());
27
+ }
28
+
29
+ async bootstrap(initSql: string): Promise<void> {
30
+ await this.#database.exec(initSql);
31
+ }
32
+
33
+ async select(table: string, query: SelectQuery = {}): Promise<ShimRow[]> {
34
+ const statement = buildSelectStatement(table, query);
35
+ const result = await this.#database.query<ShimRow>(statement.sql, statement.params);
36
+ return result.rows;
37
+ }
38
+
39
+ async insert(table: string, rows: ShimRow[], query: SelectQuery = {}): Promise<ShimRow[]> {
40
+ if (rows.length === 0) {
41
+ return [];
42
+ }
43
+
44
+ const statement = buildInsertStatement(table, rows, query);
45
+ const result = await this.#database.query<ShimRow>(statement.sql, statement.params);
46
+ return result.rows;
47
+ }
48
+
49
+ async delete(table: string, query: SelectQuery = {}): Promise<ShimRow[]> {
50
+ const statement = buildDeleteStatement(table, query);
51
+ const result = await this.#database.query<ShimRow>(statement.sql, statement.params);
52
+ return result.rows;
53
+ }
54
+
55
+ async snapshot(): Promise<string> {
56
+ const dataDir = await this.#database.dumpDataDir("gzip");
57
+ const data = encodeBase64(new Uint8Array(await dataDir.arrayBuffer()));
58
+ return JSON.stringify({
59
+ kind: "pglite-data-dir",
60
+ compression: "gzip",
61
+ type: dataDir.type,
62
+ data,
63
+ } satisfies PGliteSnapshot);
64
+ }
65
+
66
+ async restore(snapshot: string): Promise<void> {
67
+ const parsed = JSON.parse(snapshot) as PGliteSnapshot;
68
+ if (parsed.kind !== "pglite-data-dir" || parsed.compression !== "gzip") {
69
+ throw new Error("Unsupported PGlite snapshot format");
70
+ }
71
+
72
+ const dataDir = new Blob([toArrayBuffer(decodeBase64(parsed.data))], {
73
+ type: parsed.type || "application/gzip",
74
+ });
75
+ const next = await PGlite.create({ loadDataDir: dataDir });
76
+ await this.#database.close();
77
+ this.#database = next;
78
+ }
79
+
80
+ async close(): Promise<void> {
81
+ await this.#database.close();
82
+ }
83
+ }
84
+
85
+ interface SqlStatement {
86
+ sql: string;
87
+ params: ShimValue[];
88
+ }
89
+
90
+ function buildSelectStatement(table: string, query: SelectQuery): SqlStatement {
91
+ const params: ShimValue[] = [];
92
+ const where = buildWhereClause(query.filters ?? [], params);
93
+ const order = buildOrderClause(query);
94
+ return {
95
+ sql: `SELECT ${buildColumnList(query)} FROM ${quoteQualifiedIdentifier(table)}${where}${order}`,
96
+ params,
97
+ };
98
+ }
99
+
100
+ function buildInsertStatement(table: string, rows: ShimRow[], query: SelectQuery): SqlStatement {
101
+ const params: ShimValue[] = [];
102
+ const tableName = quoteQualifiedIdentifier(table);
103
+
104
+ if (rows.length === 1) {
105
+ return buildSingleInsertStatement(tableName, rows[0] ?? {}, query, params);
106
+ }
107
+
108
+ const insertCtes = rows.map((row, index) => {
109
+ const { columnsSql, valuesSql } = buildInsertValues(row, params);
110
+ return `inserted_${index} AS (INSERT INTO ${tableName}${columnsSql} ${valuesSql} RETURNING *)`;
111
+ });
112
+ const insertedSelects = rows
113
+ .map((_, index) => `SELECT * FROM inserted_${index}`)
114
+ .join(" UNION ALL ");
115
+ const where = buildWhereClause(query.filters ?? [], params);
116
+ const order = buildOrderClause(query);
117
+
118
+ return {
119
+ sql: `WITH ${insertCtes.join(", ")}, inserted AS (${insertedSelects}) SELECT ${buildColumnList(
120
+ query,
121
+ )} FROM inserted${where}${order}`,
122
+ params,
123
+ };
124
+ }
125
+
126
+ function buildSingleInsertStatement(
127
+ tableName: string,
128
+ row: ShimRow,
129
+ query: SelectQuery,
130
+ params: ShimValue[],
131
+ ): SqlStatement {
132
+ const { columnsSql, valuesSql } = buildInsertValues(row, params);
133
+ const where = buildWhereClause(query.filters ?? [], params);
134
+ const order = buildOrderClause(query);
135
+
136
+ return {
137
+ sql: `WITH inserted AS (INSERT INTO ${tableName}${columnsSql} ${valuesSql} RETURNING *) SELECT ${buildColumnList(
138
+ query,
139
+ )} FROM inserted${where}${order}`,
140
+ params,
141
+ };
142
+ }
143
+
144
+ function buildDeleteStatement(table: string, query: SelectQuery): SqlStatement {
145
+ const params: ShimValue[] = [];
146
+ const where = buildWhereClause(query.filters ?? [], params);
147
+ const order = buildOrderClause(query);
148
+
149
+ return {
150
+ sql: `WITH deleted AS (DELETE FROM ${quoteQualifiedIdentifier(
151
+ table,
152
+ )}${where} RETURNING *) SELECT ${buildColumnList(query)} FROM deleted${order}`,
153
+ params,
154
+ };
155
+ }
156
+
157
+ function buildInsertValues(
158
+ row: ShimRow,
159
+ params: ShimValue[],
160
+ ): { columnsSql: string; valuesSql: string } {
161
+ const columns = Object.keys(row);
162
+ if (columns.length === 0) {
163
+ return { columnsSql: "", valuesSql: "DEFAULT VALUES" };
164
+ }
165
+
166
+ const placeholders = columns.map((column) => {
167
+ params.push(row[column] ?? null);
168
+ return `$${params.length}`;
169
+ });
170
+
171
+ return {
172
+ columnsSql: ` (${columns.map(quoteIdentifier).join(", ")})`,
173
+ valuesSql: `VALUES (${placeholders.join(", ")})`,
174
+ };
175
+ }
176
+
177
+ function buildColumnList(query: SelectQuery): string {
178
+ const columns = query.columns ?? ["*"];
179
+ if (columns.includes("*")) {
180
+ return "*";
181
+ }
182
+ return columns.map(quoteIdentifier).join(", ");
183
+ }
184
+
185
+ function buildWhereClause(filters: QueryFilter[], params: ShimValue[]): string {
186
+ if (filters.length === 0) {
187
+ return "";
188
+ }
189
+
190
+ const clauses = filters.map((filter) => {
191
+ const column = quoteIdentifier(filter.column);
192
+ if (filter.operator === "eq" && filter.value === null) {
193
+ return `${column} IS NULL`;
194
+ }
195
+
196
+ params.push(filter.value);
197
+ return `${column} ${sqlOperatorForFilter(filter)} $${params.length}`;
198
+ });
199
+
200
+ return ` WHERE ${clauses.join(" AND ")}`;
201
+ }
202
+
203
+ function sqlOperatorForFilter(filter: QueryFilter): string {
204
+ switch (filter.operator) {
205
+ case "eq":
206
+ return "=";
207
+ case "gte":
208
+ return ">=";
209
+ case "lte":
210
+ return "<=";
211
+ }
212
+ }
213
+
214
+ function buildOrderClause(query: SelectQuery): string {
215
+ if (!query.order) {
216
+ return "";
217
+ }
218
+
219
+ return ` ORDER BY ${quoteIdentifier(query.order.column)} ${
220
+ query.order.ascending ? "ASC" : "DESC"
221
+ }`;
222
+ }
223
+
224
+ function quoteQualifiedIdentifier(identifier: string): string {
225
+ return identifier.split(".").map(quoteIdentifier).join(".");
226
+ }
227
+
228
+ function quoteIdentifier(identifier: string): string {
229
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
230
+ throw new Error(`Unsupported SQL identifier "${identifier}"`);
231
+ }
232
+ return `"${identifier}"`;
233
+ }
234
+
235
+ function encodeBase64(bytes: Uint8Array): string {
236
+ let binary = "";
237
+ const chunkSize = 0x8000;
238
+ for (let index = 0; index < bytes.length; index += chunkSize) {
239
+ binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
240
+ }
241
+ return globalThis.btoa(binary);
242
+ }
243
+
244
+ function decodeBase64(base64: string): Uint8Array {
245
+ const binary = globalThis.atob(base64);
246
+ const bytes = new Uint8Array(binary.length);
247
+ for (let index = 0; index < binary.length; index += 1) {
248
+ bytes[index] = binary.charCodeAt(index);
249
+ }
250
+ return bytes;
251
+ }
252
+
253
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
254
+ const buffer = new ArrayBuffer(bytes.byteLength);
255
+ new Uint8Array(buffer).set(bytes);
256
+ return buffer;
257
+ }
@@ -0,0 +1,189 @@
1
+ import type {
2
+ PostgrestResponse,
3
+ QueryFilter,
4
+ QueryOrder,
5
+ ShimRow,
6
+ ShimValue,
7
+ SupabaseShimDatabase,
8
+ } from "./types.js";
9
+
10
+ type QueryAction = "select" | "insert" | "delete";
11
+
12
+ interface QueryState {
13
+ action: QueryAction;
14
+ columns?: string[];
15
+ filters: QueryFilter[];
16
+ order?: QueryOrder;
17
+ rows?: ShimRow[];
18
+ wantsRows: boolean;
19
+ single: boolean;
20
+ }
21
+
22
+ type BuilderData<Single extends boolean> = Single extends true ? ShimRow | null : ShimRow[];
23
+
24
+ export class SupabasePostgrestBuilder<Single extends boolean = false>
25
+ implements PromiseLike<PostgrestResponse<BuilderData<Single>>>
26
+ {
27
+ readonly #database: SupabaseShimDatabase;
28
+ readonly #table: string;
29
+ readonly #state: QueryState;
30
+
31
+ constructor(database: SupabaseShimDatabase, table: string, state?: Partial<QueryState>) {
32
+ this.#database = database;
33
+ this.#table = table;
34
+ this.#state = {
35
+ action: state?.action ?? "select",
36
+ columns: state?.columns,
37
+ filters: state?.filters ?? [],
38
+ order: state?.order,
39
+ rows: state?.rows,
40
+ wantsRows: state?.wantsRows ?? true,
41
+ single: state?.single ?? false,
42
+ };
43
+ }
44
+
45
+ select(columns = "*"): this {
46
+ this.#state.columns = parseColumns(columns);
47
+ this.#state.wantsRows = true;
48
+ return this;
49
+ }
50
+
51
+ insert(rows: ShimRow | ShimRow[]): this {
52
+ this.#state.action = "insert";
53
+ this.#state.rows = Array.isArray(rows) ? rows : [rows];
54
+ this.#state.wantsRows = false;
55
+ return this;
56
+ }
57
+
58
+ delete(): this {
59
+ this.#state.action = "delete";
60
+ this.#state.wantsRows = false;
61
+ return this;
62
+ }
63
+
64
+ eq(column: string, value: ShimValue): this {
65
+ this.#state.filters.push({ column, operator: "eq", value });
66
+ return this;
67
+ }
68
+
69
+ gte(column: string, value: ShimValue): this {
70
+ this.#state.filters.push({ column, operator: "gte", value });
71
+ return this;
72
+ }
73
+
74
+ lte(column: string, value: ShimValue): this {
75
+ this.#state.filters.push({ column, operator: "lte", value });
76
+ return this;
77
+ }
78
+
79
+ order(column: string, options: { ascending?: boolean } = {}): this {
80
+ this.#state.order = { column, ascending: options.ascending ?? true };
81
+ return this;
82
+ }
83
+
84
+ single(): SupabasePostgrestBuilder<true> {
85
+ this.#state.single = true;
86
+ return this as unknown as SupabasePostgrestBuilder<true>;
87
+ }
88
+
89
+ then<TResult1 = PostgrestResponse<BuilderData<Single>>, TResult2 = never>(
90
+ onfulfilled?:
91
+ | ((value: PostgrestResponse<BuilderData<Single>>) => TResult1 | PromiseLike<TResult1>)
92
+ | null,
93
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
94
+ ): PromiseLike<TResult1 | TResult2> {
95
+ return this.#execute().then(onfulfilled, onrejected);
96
+ }
97
+
98
+ catch<TResult = never>(
99
+ onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
100
+ ): Promise<PostgrestResponse<BuilderData<Single>> | TResult> {
101
+ return this.#execute().catch(onrejected);
102
+ }
103
+
104
+ finally(onfinally?: (() => void) | null): Promise<PostgrestResponse<BuilderData<Single>>> {
105
+ return this.#execute().finally(onfinally ?? undefined);
106
+ }
107
+
108
+ async #execute(): Promise<PostgrestResponse<BuilderData<Single>>> {
109
+ try {
110
+ const rows = await this.#executeRows();
111
+ if (this.#state.single) {
112
+ return singleResponse(rows) as PostgrestResponse<BuilderData<Single>>;
113
+ }
114
+
115
+ return {
116
+ data: rows as BuilderData<Single>,
117
+ error: null,
118
+ count: rows.length,
119
+ status: this.#state.action === "insert" ? 201 : 200,
120
+ };
121
+ } catch (error) {
122
+ return {
123
+ data: (this.#state.single ? null : []) as BuilderData<Single>,
124
+ error: {
125
+ message: error instanceof Error ? error.message : String(error),
126
+ status: 500,
127
+ },
128
+ count: null,
129
+ status: 500,
130
+ };
131
+ }
132
+ }
133
+
134
+ async #executeRows(): Promise<ShimRow[]> {
135
+ if (this.#state.action === "insert") {
136
+ if (!this.#state.wantsRows) {
137
+ await this.#database.insert(this.#table, this.#state.rows ?? []);
138
+ return [];
139
+ }
140
+ return this.#database.insert(this.#table, this.#state.rows ?? [], {
141
+ columns: this.#state.columns,
142
+ filters: this.#state.filters,
143
+ order: this.#state.order,
144
+ });
145
+ }
146
+
147
+ if (this.#state.action === "delete") {
148
+ if (!this.#state.wantsRows) {
149
+ await this.#database.delete(this.#table, { filters: this.#state.filters });
150
+ return [];
151
+ }
152
+ return this.#database.delete(this.#table, {
153
+ columns: this.#state.columns,
154
+ filters: this.#state.filters,
155
+ order: this.#state.order,
156
+ });
157
+ }
158
+
159
+ return this.#database.select(this.#table, {
160
+ columns: this.#state.columns,
161
+ filters: this.#state.filters,
162
+ order: this.#state.order,
163
+ });
164
+ }
165
+ }
166
+
167
+ function singleResponse(rows: ShimRow[]): PostgrestResponse<ShimRow | null> {
168
+ if (rows.length === 1) {
169
+ return { data: rows[0], error: null, count: 1, status: 200 };
170
+ }
171
+
172
+ return {
173
+ data: null,
174
+ error: {
175
+ code: "PGRST116",
176
+ message: "JSON object requested, multiple (or no) rows returned",
177
+ status: 406,
178
+ },
179
+ count: rows.length,
180
+ status: 406,
181
+ };
182
+ }
183
+
184
+ function parseColumns(columns: string): string[] {
185
+ return columns
186
+ .split(",")
187
+ .map((column) => column.trim())
188
+ .filter(Boolean);
189
+ }
@@ -0,0 +1,13 @@
1
+ import type { SnapshotStorage } from "./types.js";
2
+
3
+ export class InMemorySnapshotStorage implements SnapshotStorage {
4
+ readonly #snapshots = new Map<string, string>();
5
+
6
+ get(key: string): string | null {
7
+ return this.#snapshots.get(key) ?? null;
8
+ }
9
+
10
+ set(key: string, snapshot: string): void {
11
+ this.#snapshots.set(key, snapshot);
12
+ }
13
+ }
package/src/types.ts ADDED
@@ -0,0 +1,66 @@
1
+ export type ShimValue = string | number | boolean | null;
2
+
3
+ export type ShimRow = Record<string, ShimValue>;
4
+
5
+ export type AuthChangeEvent = "SIGNED_UP" | "SIGNED_IN" | "SIGNED_OUT";
6
+
7
+ export interface ShimUser {
8
+ id: string;
9
+ email: string;
10
+ }
11
+
12
+ export interface ShimSession {
13
+ access_token: string;
14
+ token_type: "bearer";
15
+ user: ShimUser;
16
+ }
17
+
18
+ export interface SupabaseShimError {
19
+ code?: string;
20
+ message: string;
21
+ status?: number;
22
+ }
23
+
24
+ export interface SupabaseAuthResponse<T> {
25
+ data: T;
26
+ error: SupabaseShimError | null;
27
+ }
28
+
29
+ export interface PostgrestResponse<T> {
30
+ data: T;
31
+ error: SupabaseShimError | null;
32
+ count: number | null;
33
+ status: number;
34
+ }
35
+
36
+ export interface QueryFilter {
37
+ column: string;
38
+ operator: "eq" | "gte" | "lte";
39
+ value: ShimValue;
40
+ }
41
+
42
+ export interface QueryOrder {
43
+ column: string;
44
+ ascending: boolean;
45
+ }
46
+
47
+ export interface SelectQuery {
48
+ columns?: string[];
49
+ filters?: QueryFilter[];
50
+ order?: QueryOrder;
51
+ }
52
+
53
+ export interface SupabaseShimDatabase {
54
+ bootstrap(initSql: string): Promise<void>;
55
+ select(table: string, query?: SelectQuery): Promise<ShimRow[]>;
56
+ insert(table: string, rows: ShimRow[], query?: SelectQuery): Promise<ShimRow[]>;
57
+ delete(table: string, query?: SelectQuery): Promise<ShimRow[]>;
58
+ snapshot(): Promise<string>;
59
+ restore(snapshot: string): Promise<void>;
60
+ close?(): Promise<void>;
61
+ }
62
+
63
+ export interface SnapshotStorage {
64
+ get(key: string): Promise<string | null> | string | null;
65
+ set(key: string, snapshot: string): Promise<void> | void;
66
+ }