@staticbot/base44-supabase-shim 0.3.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,115 @@
1
+ import { SupabaseClient } from '@supabase/supabase-js';
2
+
3
+ type Json = string | number | boolean | null | Json[] | {
4
+ [key: string]: Json;
5
+ };
6
+ interface EntityMapping {
7
+ schema: string;
8
+ table: string;
9
+ }
10
+ interface ClientOptions {
11
+ /** Supabase project URL, e.g. https://api.erp.local */
12
+ supabaseUrl: string;
13
+ /** anon public key for browser use */
14
+ supabaseAnonKey: string;
15
+ /** service_role key — only set in trusted server contexts */
16
+ supabaseServiceRoleKey?: string;
17
+ /**
18
+ * Postgres schema for app-specific entities.
19
+ * E.g. 'propertyflow' / 'fms' / 'finance' / 'construction'.
20
+ */
21
+ schemaPrefix: string;
22
+ /**
23
+ * Postgres schema for shared entities (Customer, Company, User, Role...).
24
+ * Default: 'core'.
25
+ */
26
+ sharedSchema?: string;
27
+ /**
28
+ * List of entity names that live in `sharedSchema` instead of `schemaPrefix`.
29
+ * Default: ['Customer', 'Company', 'User', 'Role', 'Department', 'Notification', 'AuditLog'].
30
+ */
31
+ sharedEntities?: string[];
32
+ /**
33
+ * Explicit per-entity mapping overrides. Use this if the auto-derived
34
+ * snake_case+pluralize naming doesn't match the actual table name.
35
+ */
36
+ entityMap?: Record<string, EntityMapping>;
37
+ /**
38
+ * Optional pre-built supabase client. If omitted, one is created from the keys.
39
+ * Useful in tests / when you already have a client elsewhere.
40
+ */
41
+ client?: SupabaseClient;
42
+ }
43
+ interface OrderBy {
44
+ /** Field name. Prefix with '-' for descending (Base44 convention). */
45
+ field: string;
46
+ ascending?: boolean;
47
+ }
48
+ /** Where-clause filter object as used by Base44.
49
+ *
50
+ * Three accepted shapes per field, all translated to PostgREST:
51
+ *
52
+ * 1. Equality / array shorthand: `{ status: 'active', id: ['a','b'] }`
53
+ * 2. Base44 SDK Mongo operators: `{ price: { $gte: 10, $lt: 100 }, status: { $in: ['a','b'] } }`
54
+ * 3. Legacy {op,value} form: `{ amount: { op: 'gt', value: 100 } }`
55
+ *
56
+ * Shape (2) is what `@base44/sdk` emits — its `entities.X.filter(q, ...)`
57
+ * forwards `q` to Base44's REST as URL-encoded JSON with $-prefixed ops.
58
+ * Apps that ran against Base44's managed backend pass these exact shapes.
59
+ */
60
+ type MongoFilter = {
61
+ $eq?: unknown;
62
+ $ne?: unknown;
63
+ $gt?: unknown;
64
+ $gte?: unknown;
65
+ $lt?: unknown;
66
+ $lte?: unknown;
67
+ $in?: unknown[];
68
+ $nin?: unknown[];
69
+ $like?: string;
70
+ $ilike?: string;
71
+ };
72
+ type FilterValue = string | number | boolean | null | string[] | number[] | MongoFilter | {
73
+ op: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' | 'in';
74
+ value: unknown;
75
+ };
76
+ type FilterObject = Record<string, FilterValue>;
77
+ interface EntityApi<T = Record<string, unknown>> {
78
+ list(orderBy?: string | OrderBy, limit?: number, skip?: number): Promise<T[]>;
79
+ filter(where: FilterObject, orderBy?: string | OrderBy, limit?: number, skip?: number): Promise<T[]>;
80
+ get(id: string): Promise<T | null>;
81
+ /** Alias for get() — Base44 SDK exposes both names. */
82
+ read(id: string): Promise<T | null>;
83
+ create(body: Partial<T>): Promise<T>;
84
+ /** Insert multiple rows in one call. */
85
+ bulkCreate(rows: Array<Partial<T>>): Promise<T[]>;
86
+ /** Update many rows by id; each row must contain its own `id`. */
87
+ bulkUpdate(rows: Array<Partial<T> & {
88
+ id: string;
89
+ }>): Promise<T[]>;
90
+ update(id: string, body: Partial<T>): Promise<T>;
91
+ /**
92
+ * Apply the same patch to every row matching `where`. Accepts either a flat
93
+ * patch or a MongoDB-style `{ $set: {...} }` envelope (the Base44 SDK form).
94
+ */
95
+ updateMany(where: FilterObject, update: Partial<T> | {
96
+ $set?: Partial<T>;
97
+ }): Promise<T[]>;
98
+ delete(id: string): Promise<void>;
99
+ /** Delete every row matching `where`. */
100
+ deleteMany(where: FilterObject): Promise<void>;
101
+ /**
102
+ * Subscribe to row changes via Supabase Realtime.
103
+ * Returns an unsubscribe function (matches Base44 convention).
104
+ */
105
+ subscribe(callback: (event: {
106
+ type: 'insert' | 'update' | 'delete';
107
+ new?: T;
108
+ old?: T;
109
+ }) => void): () => void;
110
+ }
111
+ type EntitiesProxy = {
112
+ [entityName: string]: EntityApi;
113
+ };
114
+
115
+ export type { ClientOptions as C, EntityMapping as E, FilterObject as F, Json as J, MongoFilter as M, OrderBy as O, EntitiesProxy as a, EntityApi as b, FilterValue as c };
@@ -0,0 +1,115 @@
1
+ import { SupabaseClient } from '@supabase/supabase-js';
2
+
3
+ type Json = string | number | boolean | null | Json[] | {
4
+ [key: string]: Json;
5
+ };
6
+ interface EntityMapping {
7
+ schema: string;
8
+ table: string;
9
+ }
10
+ interface ClientOptions {
11
+ /** Supabase project URL, e.g. https://api.erp.local */
12
+ supabaseUrl: string;
13
+ /** anon public key for browser use */
14
+ supabaseAnonKey: string;
15
+ /** service_role key — only set in trusted server contexts */
16
+ supabaseServiceRoleKey?: string;
17
+ /**
18
+ * Postgres schema for app-specific entities.
19
+ * E.g. 'propertyflow' / 'fms' / 'finance' / 'construction'.
20
+ */
21
+ schemaPrefix: string;
22
+ /**
23
+ * Postgres schema for shared entities (Customer, Company, User, Role...).
24
+ * Default: 'core'.
25
+ */
26
+ sharedSchema?: string;
27
+ /**
28
+ * List of entity names that live in `sharedSchema` instead of `schemaPrefix`.
29
+ * Default: ['Customer', 'Company', 'User', 'Role', 'Department', 'Notification', 'AuditLog'].
30
+ */
31
+ sharedEntities?: string[];
32
+ /**
33
+ * Explicit per-entity mapping overrides. Use this if the auto-derived
34
+ * snake_case+pluralize naming doesn't match the actual table name.
35
+ */
36
+ entityMap?: Record<string, EntityMapping>;
37
+ /**
38
+ * Optional pre-built supabase client. If omitted, one is created from the keys.
39
+ * Useful in tests / when you already have a client elsewhere.
40
+ */
41
+ client?: SupabaseClient;
42
+ }
43
+ interface OrderBy {
44
+ /** Field name. Prefix with '-' for descending (Base44 convention). */
45
+ field: string;
46
+ ascending?: boolean;
47
+ }
48
+ /** Where-clause filter object as used by Base44.
49
+ *
50
+ * Three accepted shapes per field, all translated to PostgREST:
51
+ *
52
+ * 1. Equality / array shorthand: `{ status: 'active', id: ['a','b'] }`
53
+ * 2. Base44 SDK Mongo operators: `{ price: { $gte: 10, $lt: 100 }, status: { $in: ['a','b'] } }`
54
+ * 3. Legacy {op,value} form: `{ amount: { op: 'gt', value: 100 } }`
55
+ *
56
+ * Shape (2) is what `@base44/sdk` emits — its `entities.X.filter(q, ...)`
57
+ * forwards `q` to Base44's REST as URL-encoded JSON with $-prefixed ops.
58
+ * Apps that ran against Base44's managed backend pass these exact shapes.
59
+ */
60
+ type MongoFilter = {
61
+ $eq?: unknown;
62
+ $ne?: unknown;
63
+ $gt?: unknown;
64
+ $gte?: unknown;
65
+ $lt?: unknown;
66
+ $lte?: unknown;
67
+ $in?: unknown[];
68
+ $nin?: unknown[];
69
+ $like?: string;
70
+ $ilike?: string;
71
+ };
72
+ type FilterValue = string | number | boolean | null | string[] | number[] | MongoFilter | {
73
+ op: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'ilike' | 'in';
74
+ value: unknown;
75
+ };
76
+ type FilterObject = Record<string, FilterValue>;
77
+ interface EntityApi<T = Record<string, unknown>> {
78
+ list(orderBy?: string | OrderBy, limit?: number, skip?: number): Promise<T[]>;
79
+ filter(where: FilterObject, orderBy?: string | OrderBy, limit?: number, skip?: number): Promise<T[]>;
80
+ get(id: string): Promise<T | null>;
81
+ /** Alias for get() — Base44 SDK exposes both names. */
82
+ read(id: string): Promise<T | null>;
83
+ create(body: Partial<T>): Promise<T>;
84
+ /** Insert multiple rows in one call. */
85
+ bulkCreate(rows: Array<Partial<T>>): Promise<T[]>;
86
+ /** Update many rows by id; each row must contain its own `id`. */
87
+ bulkUpdate(rows: Array<Partial<T> & {
88
+ id: string;
89
+ }>): Promise<T[]>;
90
+ update(id: string, body: Partial<T>): Promise<T>;
91
+ /**
92
+ * Apply the same patch to every row matching `where`. Accepts either a flat
93
+ * patch or a MongoDB-style `{ $set: {...} }` envelope (the Base44 SDK form).
94
+ */
95
+ updateMany(where: FilterObject, update: Partial<T> | {
96
+ $set?: Partial<T>;
97
+ }): Promise<T[]>;
98
+ delete(id: string): Promise<void>;
99
+ /** Delete every row matching `where`. */
100
+ deleteMany(where: FilterObject): Promise<void>;
101
+ /**
102
+ * Subscribe to row changes via Supabase Realtime.
103
+ * Returns an unsubscribe function (matches Base44 convention).
104
+ */
105
+ subscribe(callback: (event: {
106
+ type: 'insert' | 'update' | 'delete';
107
+ new?: T;
108
+ old?: T;
109
+ }) => void): () => void;
110
+ }
111
+ type EntitiesProxy = {
112
+ [entityName: string]: EntityApi;
113
+ };
114
+
115
+ export type { ClientOptions as C, EntityMapping as E, FilterObject as F, Json as J, MongoFilter as M, OrderBy as O, EntitiesProxy as a, EntityApi as b, FilterValue as c };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@staticbot/base44-supabase-shim",
3
+ "version": "0.3.0",
4
+ "description": "Drop-in shim mimicking @base44/sdk API but routing to a Supabase backend. Used by Staticbot's Base44 native migration to keep migrated apps building without an npm registry round-trip.",
5
+ "license": "MIT",
6
+ "private": false,
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/staticbot/staticbot-base44-supabase-shim.git"
10
+ },
11
+ "type": "module",
12
+ "main": "./dist/index.cjs",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "require": "./dist/index.cjs"
20
+ },
21
+ "./server": {
22
+ "types": "./dist/server.d.ts",
23
+ "import": "./dist/server.js",
24
+ "require": "./dist/server.cjs"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "src",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsup",
35
+ "dev": "tsup --watch",
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest"
39
+ },
40
+ "peerDependencies": {
41
+ "@supabase/supabase-js": "^2.45.0"
42
+ },
43
+ "devDependencies": {
44
+ "@supabase/supabase-js": "^2.45.0",
45
+ "tsup": "^8.3.0",
46
+ "typescript": "^5.6.0",
47
+ "vitest": "^2.1.0"
48
+ }
49
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,101 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+
3
+ export interface SignInArgs {
4
+ email: string;
5
+ password: string;
6
+ }
7
+
8
+ export interface SignUpArgs {
9
+ email: string;
10
+ password: string;
11
+ metadata?: Record<string, unknown>;
12
+ }
13
+
14
+ export interface AuthOptions {
15
+ /**
16
+ * Browser path the redirectToLogin() shim should send users to.
17
+ * Default: '/login'.
18
+ */
19
+ loginPath?: string;
20
+ }
21
+
22
+ export function makeAuth(client: SupabaseClient, opts: AuthOptions = {}) {
23
+ const loginPath = opts.loginPath ?? '/login';
24
+
25
+ return {
26
+ async signIn({ email, password }: SignInArgs) {
27
+ const { data, error } = await client.auth.signInWithPassword({ email, password });
28
+ if (error) throw error;
29
+ return data;
30
+ },
31
+ async signUp({ email, password, metadata }: SignUpArgs) {
32
+ const { data, error } = await client.auth.signUp({
33
+ email,
34
+ password,
35
+ options: { data: metadata ?? {} },
36
+ });
37
+ if (error) throw error;
38
+ return data;
39
+ },
40
+ async signOut() {
41
+ const { error } = await client.auth.signOut();
42
+ if (error) throw error;
43
+ },
44
+ /** Base44 alias for signOut. */
45
+ async logout() {
46
+ const { error } = await client.auth.signOut();
47
+ if (error) throw error;
48
+ },
49
+ async getUser() {
50
+ const { data, error } = await client.auth.getUser();
51
+ if (error) throw error;
52
+ return data.user;
53
+ },
54
+ /** Base44 alias for getUser. Returns the current user object or throws if not signed in. */
55
+ async me() {
56
+ const { data, error } = await client.auth.getUser();
57
+ if (error) throw error;
58
+ if (!data.user) throw new Error('Not authenticated');
59
+ return data.user;
60
+ },
61
+ /**
62
+ * Base44 alias for updating the current user's metadata. Accepts arbitrary
63
+ * key/value pairs that get stored in `user_metadata`.
64
+ */
65
+ async updateMe(metadata: Record<string, unknown>) {
66
+ const { data, error } = await client.auth.updateUser({ data: metadata });
67
+ if (error) throw error;
68
+ return data.user;
69
+ },
70
+ async getSession() {
71
+ const { data, error } = await client.auth.getSession();
72
+ if (error) throw error;
73
+ return data.session;
74
+ },
75
+ /**
76
+ * Base44 used to redirect SPA users to its hosted login page. Self-host
77
+ * has no hosted login, so this just navigates to the configured local
78
+ * loginPath. Override the path via createClient({ ..., authLoginPath: '/x' }).
79
+ */
80
+ redirectToLogin(returnUrl?: string) {
81
+ if (typeof window === 'undefined') return;
82
+ // Already on the login route → no-op. Prevents redirect loops when an
83
+ // unauthenticated check on /login itself fires another redirectToLogin.
84
+ if (window.location.pathname === loginPath) return;
85
+ // Defensive: if the returnUrl already points back to a login page (cyclic
86
+ // chain from old buggy state), drop it. Also drop if it's absurdly long.
87
+ let safeReturn: string | undefined = returnUrl;
88
+ if (safeReturn) {
89
+ const looksCyclic = safeReturn.includes(`${loginPath}?next=`) || safeReturn.includes(`${loginPath}%3F`);
90
+ if (looksCyclic || safeReturn.length > 1024) safeReturn = undefined;
91
+ }
92
+ const url = safeReturn
93
+ ? `${loginPath}?next=${encodeURIComponent(safeReturn)}`
94
+ : loginPath;
95
+ window.location.assign(url);
96
+ },
97
+ onAuthStateChange(cb: Parameters<SupabaseClient['auth']['onAuthStateChange']>[0]) {
98
+ return client.auth.onAuthStateChange(cb);
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,267 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+ import type {
3
+ ClientOptions,
4
+ EntitiesProxy,
5
+ EntityApi,
6
+ EntityMapping,
7
+ FilterObject,
8
+ FilterValue,
9
+ OrderBy,
10
+ } from './types.js';
11
+
12
+ const DEFAULT_SHARED_ENTITIES = [
13
+ // Auth/identity (option C: shared)
14
+ 'User',
15
+ 'Role',
16
+ 'UserRole', // 'finance.user_roles' was 404 — UserRole belongs in shared
17
+ 'Department',
18
+ // Org (option C: shared)
19
+ 'Company',
20
+ // Cross-app entities exposed by core schema
21
+ 'AuditLog',
22
+ // Note: Customer is intentionally NOT shared — each app's Customer schema
23
+ // differs significantly (CRM vs Facility vs Construction).
24
+ ];
25
+
26
+ /** PascalCase → snake_case + naive pluralization (s, ies, es). */
27
+ export function defaultEntityToTable(entityName: string): string {
28
+ const snake = entityName.replace(/([A-Z])/g, (m, c, i) =>
29
+ i === 0 ? c.toLowerCase() : '_' + c.toLowerCase(),
30
+ );
31
+ if (snake.endsWith('y') && !/[aeiou]y$/.test(snake)) return snake.slice(0, -1) + 'ies';
32
+ if (/(s|x|z|ch|sh)$/.test(snake)) return snake + 'es';
33
+ return snake + 's';
34
+ }
35
+
36
+ export function resolveEntityMapping(
37
+ entityName: string,
38
+ opts: Required<Pick<ClientOptions, 'schemaPrefix'>> &
39
+ Pick<ClientOptions, 'sharedSchema' | 'sharedEntities' | 'entityMap'>,
40
+ ): EntityMapping {
41
+ if (opts.entityMap?.[entityName]) return opts.entityMap[entityName];
42
+ const sharedEntities = opts.sharedEntities ?? DEFAULT_SHARED_ENTITIES;
43
+ const sharedSchema = opts.sharedSchema ?? 'core';
44
+ const schema = sharedEntities.includes(entityName) ? sharedSchema : opts.schemaPrefix;
45
+ return { schema, table: defaultEntityToTable(entityName) };
46
+ }
47
+
48
+ /**
49
+ * Parse Base44-style orderBy:
50
+ * - string '-created_date' → { field: 'created_date', ascending: false }
51
+ * - string 'name' → { field: 'name', ascending: true }
52
+ * - object passes through.
53
+ */
54
+ export function parseOrderBy(input: string | OrderBy | undefined): OrderBy | undefined {
55
+ if (!input) return undefined;
56
+ if (typeof input === 'object') return input;
57
+ if (input.startsWith('-')) return { field: input.slice(1), ascending: false };
58
+ return { field: input, ascending: true };
59
+ }
60
+
61
+ const MONGO_OPS = [
62
+ '$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin', '$like', '$ilike',
63
+ ] as const;
64
+
65
+ function isMongoFilter(v: unknown): v is Record<string, unknown> {
66
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return false;
67
+ return Object.keys(v as object).some((k) =>
68
+ (MONGO_OPS as readonly string[]).includes(k),
69
+ );
70
+ }
71
+
72
+ function applyMongoOps(query: any, field: string, ops: Record<string, unknown>): any {
73
+ for (const [op, value] of Object.entries(ops)) {
74
+ switch (op) {
75
+ case '$eq': query = query.eq(field, value); break;
76
+ case '$ne': query = query.neq(field, value); break;
77
+ case '$gt': query = query.gt(field, value); break;
78
+ case '$gte': query = query.gte(field, value); break;
79
+ case '$lt': query = query.lt(field, value); break;
80
+ case '$lte': query = query.lte(field, value); break;
81
+ case '$in': query = query.in(field, value as unknown[]); break;
82
+ case '$nin': query = query.not('in', `(${(value as unknown[]).map(v => JSON.stringify(v)).join(',')})`); break;
83
+ case '$like': query = query.like(field, value as string); break;
84
+ case '$ilike': query = query.ilike(field, value as string); break;
85
+ // Unknown ops are ignored rather than throwing — keeps shim forward-
86
+ // compatible if Base44 ships new operators we haven't seen yet.
87
+ }
88
+ }
89
+ return query;
90
+ }
91
+
92
+ function applyFilter(query: any, where: FilterObject): any {
93
+ for (const [field, raw] of Object.entries(where)) {
94
+ const v = raw as FilterValue;
95
+ if (v === null) {
96
+ query = query.is(field, null);
97
+ } else if (isMongoFilter(v)) {
98
+ query = applyMongoOps(query, field, v as Record<string, unknown>);
99
+ } else if (typeof v === 'object' && !Array.isArray(v) && 'op' in v) {
100
+ // legacy {op, value} form — translate to the Mongo path so behaviour stays in sync
101
+ const { op, value } = v as { op: string; value: unknown };
102
+ query = applyMongoOps(query, field, { [`$${op}`]: value });
103
+ } else if (Array.isArray(v)) {
104
+ query = query.in(field, v);
105
+ } else {
106
+ query = query.eq(field, v);
107
+ }
108
+ }
109
+ return query;
110
+ }
111
+
112
+ function applyRange(query: any, limit: number | undefined, skip: number | undefined): any {
113
+ if (typeof skip === 'number' && typeof limit === 'number') {
114
+ return query.range(skip, skip + limit - 1);
115
+ }
116
+ if (typeof limit === 'number') return query.limit(limit);
117
+ return query;
118
+ }
119
+
120
+ export function makeEntityApi(client: SupabaseClient, mapping: EntityMapping): EntityApi {
121
+ const from = () => client.schema(mapping.schema as never).from(mapping.table);
122
+
123
+ // Treat "table not in schema cache" (PGRST205) and 404 as empty result, not error.
124
+ // Apps reference many entities that may not exist in self-host schema yet —
125
+ // surfacing as empty lets the UI render skeletons instead of crashing.
126
+ const isMissingTableError = (e: unknown): boolean => {
127
+ if (!e || typeof e !== 'object') return false;
128
+ const code = (e as { code?: string }).code;
129
+ const status = (e as { status?: number }).status;
130
+ return code === 'PGRST205' || code === '42P01' || status === 404;
131
+ };
132
+ const warnedTables = new Set<string>();
133
+ const warnMissing = () => {
134
+ const key = `${mapping.schema}.${mapping.table}`;
135
+ if (warnedTables.has(key)) return;
136
+ warnedTables.add(key);
137
+ if (typeof console !== 'undefined') console.warn(`[base44-shim] table ${key} not found — returning empty result`);
138
+ };
139
+
140
+ return {
141
+ async list(orderBy, limit, skip) {
142
+ let q = from().select('*');
143
+ const ob = parseOrderBy(orderBy);
144
+ if (ob) q = q.order(ob.field, { ascending: ob.ascending ?? true });
145
+ q = applyRange(q, limit, skip);
146
+ const { data, error } = await q;
147
+ if (error) {
148
+ if (isMissingTableError(error)) { warnMissing(); return []; }
149
+ throw error;
150
+ }
151
+ return data ?? [];
152
+ },
153
+ async filter(where, orderBy, limit, skip) {
154
+ let q = from().select('*');
155
+ q = applyFilter(q, where);
156
+ const ob = parseOrderBy(orderBy);
157
+ if (ob) q = q.order(ob.field, { ascending: ob.ascending ?? true });
158
+ q = applyRange(q, limit, skip);
159
+ const { data, error } = await q;
160
+ if (error) {
161
+ if (isMissingTableError(error)) { warnMissing(); return []; }
162
+ throw error;
163
+ }
164
+ return data ?? [];
165
+ },
166
+ async get(id) {
167
+ const { data, error } = await from().select('*').eq('id', id).maybeSingle();
168
+ if (error) {
169
+ if (isMissingTableError(error)) { warnMissing(); return null; }
170
+ throw error;
171
+ }
172
+ return data ?? null;
173
+ },
174
+ async read(id) {
175
+ const { data, error } = await from().select('*').eq('id', id).maybeSingle();
176
+ if (error) {
177
+ if (isMissingTableError(error)) { warnMissing(); return null; }
178
+ throw error;
179
+ }
180
+ return data ?? null;
181
+ },
182
+ async create(body) {
183
+ const { data, error } = await from().insert(body).select().single();
184
+ if (error) throw error;
185
+ return data;
186
+ },
187
+ async bulkCreate(rows) {
188
+ if (!rows.length) return [];
189
+ const { data, error } = await from().insert(rows).select();
190
+ if (error) throw error;
191
+ return data ?? [];
192
+ },
193
+ async update(id, body) {
194
+ const { data, error } = await from().update(body).eq('id', id).select().single();
195
+ if (error) throw error;
196
+ return data;
197
+ },
198
+ async bulkUpdate(rows) {
199
+ if (!rows.length) return [];
200
+ // PostgREST has no native multi-patch with per-row payloads; do one
201
+ // round-trip per row. Fine for hundreds — re-evaluate if a caller
202
+ // starts pushing thousands and we need a CTE-based RPC.
203
+ const results = await Promise.all(
204
+ rows.map(({ id, ...patch }) =>
205
+ from().update(patch).eq('id', id).select().single(),
206
+ ),
207
+ );
208
+ const errors = results.map((r) => r.error).filter(Boolean);
209
+ if (errors.length) throw errors[0];
210
+ return results.map((r) => r.data) as any[];
211
+ },
212
+ async updateMany(where, update) {
213
+ // Base44 SDK / Mongo style: `{ $set: { field: value } }` wraps the patch.
214
+ // Unwrap so the same call works whether the caller used $set or a flat object.
215
+ const patch = (update as { $set?: unknown })?.$set ?? update;
216
+ let q = from().update(patch as Record<string, unknown>);
217
+ q = applyFilter(q, where);
218
+ const { data, error } = await q.select();
219
+ if (error) throw error;
220
+ return data ?? [];
221
+ },
222
+ async delete(id) {
223
+ const { error } = await from().delete().eq('id', id);
224
+ if (error) throw error;
225
+ },
226
+ async deleteMany(where) {
227
+ let q = from().delete();
228
+ q = applyFilter(q, where);
229
+ const { error } = await q;
230
+ if (error) throw error;
231
+ },
232
+ subscribe(callback) {
233
+ const channel = client
234
+ .channel(`${mapping.schema}.${mapping.table}.${Math.random().toString(36).slice(2, 8)}`)
235
+ .on(
236
+ 'postgres_changes' as never,
237
+ { event: '*', schema: mapping.schema, table: mapping.table },
238
+ (payload: { eventType: string; new?: unknown; old?: unknown }) => {
239
+ const type = payload.eventType.toLowerCase() as 'insert' | 'update' | 'delete';
240
+ callback({ type, new: payload.new as never, old: payload.old as never });
241
+ },
242
+ )
243
+ .subscribe();
244
+ return () => {
245
+ void client.removeChannel(channel);
246
+ };
247
+ },
248
+ };
249
+ }
250
+
251
+ export function makeEntitiesProxy(
252
+ client: SupabaseClient,
253
+ opts: Required<Pick<ClientOptions, 'schemaPrefix'>> &
254
+ Pick<ClientOptions, 'sharedSchema' | 'sharedEntities' | 'entityMap'>,
255
+ ): EntitiesProxy {
256
+ const cache: Record<string, EntityApi> = {};
257
+ return new Proxy({} as EntitiesProxy, {
258
+ get(_target, prop: string) {
259
+ if (typeof prop !== 'string') return undefined;
260
+ if (!cache[prop]) {
261
+ const mapping = resolveEntityMapping(prop, opts);
262
+ cache[prop] = makeEntityApi(client, mapping);
263
+ }
264
+ return cache[prop];
265
+ },
266
+ });
267
+ }
@@ -0,0 +1,28 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+
3
+ /** Mirror of base44.functions — Supabase Edge Functions invoke wrapper.
4
+ *
5
+ * Returns the raw body. On HTTP error, throws an Error with the response body attached.
6
+ */
7
+ export function makeFunctions(client: SupabaseClient) {
8
+ return {
9
+ /** Invoke an edge function by name, passing JSON body. Returns parsed JSON. */
10
+ async invoke<T = unknown>(name: string, payload?: Record<string, unknown>): Promise<T> {
11
+ const { data, error } = await client.functions.invoke<T>(name, {
12
+ body: payload,
13
+ });
14
+ if (error) throw error;
15
+ return data as T;
16
+ },
17
+ /** Lower-level: invoke and return Response so caller can handle non-JSON. */
18
+ async fetch(name: string, init?: RequestInit): Promise<Response> {
19
+ const url = `${(client as unknown as { supabaseUrl: string }).supabaseUrl}/functions/v1/${name}`;
20
+ const headers = new Headers(init?.headers);
21
+ const session = await client.auth.getSession();
22
+ const token = session.data.session?.access_token;
23
+ if (token) headers.set('Authorization', `Bearer ${token}`);
24
+ headers.set('apikey', (client as unknown as { supabaseKey: string }).supabaseKey);
25
+ return fetch(url, { ...init, headers });
26
+ },
27
+ };
28
+ }