Skip to content

Commit 93348c6

Browse files
Merge pull request #98 from InsForge/feat/database-schema-selection
feat(database): custom schema selection via .schema()
2 parents 9cb2f5b + 71566d9 commit 93348c6

7 files changed

Lines changed: 109 additions & 7 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,31 @@ const { data, error } = await insforge.database
185185
.eq("id", postId);
186186
```
187187

188+
#### Selecting a schema
189+
190+
Queries hit the `public` schema by default. Target a custom schema by chaining `.schema()` — the table name stays bare (it maps to PostgREST's `Accept-Profile`/`Content-Profile` header):
191+
192+
```javascript
193+
const { data } = await insforge.database
194+
.schema("analytics")
195+
.from("events")
196+
.select("*");
197+
198+
await insforge.database.schema("analytics").rpc("rollup", { day: "2026-01-01" });
199+
```
200+
201+
Or set a default schema for every query when creating the client:
202+
203+
```javascript
204+
const insforge = createClient({
205+
baseUrl: "...",
206+
anonKey: "...",
207+
db: { schema: "analytics" },
208+
});
209+
```
210+
211+
The schema must be exposed by the backend, and access is still gated by grants + RLS like `public`. Older backends that don't expose the schema return PostgREST `PGRST106` rather than silently falling back.
212+
188213
### File Storage
189214

190215
```javascript

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@insforge/sdk",
3-
"version": "1.4.2",
3+
"version": "1.4.3",
44
"description": "Official JavaScript/TypeScript client for InsForge Backend-as-a-Service platform",
55
"main": "./dist/index.js",
66
"module": "./dist/index.mjs",

src/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export class InsForgeClient {
8383
isServerMode: config.isServerMode ?? !!accessToken,
8484
detectOAuthCallback: config.auth?.detectOAuthCallback,
8585
});
86-
this.database = new Database(this.http);
86+
this.database = new Database(this.http, config.db?.schema);
8787
this.storage = new Storage(this.http);
8888
this.ai = new AI(this.http);
8989
this.functions = new Functions(this.http, config.functionsUrl);

src/modules/__tests__/database-postgrest.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function makeDatabase(
1515
fetchFn: ReturnType<typeof vi.fn>,
1616
overrides: Record<string, unknown> = {},
1717
accessToken: string | null = 'old-token',
18+
defaultSchema?: string,
1819
) {
1920
const tokenManager = new TokenManager();
2021
if (accessToken) {
@@ -34,7 +35,7 @@ function makeDatabase(
3435
http.setAuthToken(accessToken);
3536

3637
return {
37-
database: new Database(http),
38+
database: new Database(http, defaultSchema),
3839
tokenManager,
3940
};
4041
}
@@ -147,6 +148,48 @@ describe('Database PostgREST auth refresh', () => {
147148
expect(fetchFn).toHaveBeenCalledOnce();
148149
});
149150

151+
it('sends Accept-Profile when a schema is selected on a read', async () => {
152+
const fetchFn = vi.fn().mockResolvedValueOnce(jsonResponse(200, [{ id: 1 }]));
153+
const { database } = makeDatabase(fetchFn);
154+
155+
await database.schema('analytics').from('events').select('id');
156+
157+
const [url, init] = fetchFn.mock.calls[0];
158+
expect(url).toBe('http://localhost:7130/api/database/records/events?select=id');
159+
expect(new Headers(init.headers).get('Accept-Profile')).toBe('analytics');
160+
});
161+
162+
it('sends Content-Profile when a schema is selected on a write', async () => {
163+
const fetchFn = vi.fn().mockResolvedValueOnce(jsonResponse(201, [{ id: 1 }]));
164+
const { database } = makeDatabase(fetchFn);
165+
166+
await database.schema('analytics').from('events').insert({ name: 'signup' });
167+
168+
const [, init] = fetchFn.mock.calls[0];
169+
expect(new Headers(init.headers).get('Content-Profile')).toBe('analytics');
170+
});
171+
172+
it('sends the schema profile header for rpc calls', async () => {
173+
const fetchFn = vi.fn().mockResolvedValueOnce(jsonResponse(200, [{ ok: true }]));
174+
const { database } = makeDatabase(fetchFn);
175+
176+
await database.schema('analytics').rpc('rollup', { day: '2026-01-01' });
177+
178+
const [url, init] = fetchFn.mock.calls[0];
179+
expect(url).toBe('http://localhost:7130/api/database/rpc/rollup');
180+
expect(new Headers(init.headers).get('Content-Profile')).toBe('analytics');
181+
});
182+
183+
it('applies a default schema from config to every query', async () => {
184+
const fetchFn = vi.fn().mockResolvedValueOnce(jsonResponse(200, [{ id: 1 }]));
185+
const { database } = makeDatabase(fetchFn, {}, 'old-token', 'analytics');
186+
187+
await database.from('events').select('id');
188+
189+
const [, init] = fetchFn.mock.calls[0];
190+
expect(new Headers(init.headers).get('Accept-Profile')).toBe('analytics');
191+
});
192+
150193
it('does not refresh database requests in server mode', async () => {
151194
const fetchFn = vi.fn().mockResolvedValueOnce(
152195
jsonResponse(

src/modules/database-postgrest.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,35 @@ function createInsForgePostgrestFetch(httpClient: HttpClient): typeof fetch {
5151
export class Database {
5252
private postgrest: PostgrestClient<any, any, any>;
5353

54-
constructor(httpClient: HttpClient) {
55-
// Create postgrest client with custom fetch
54+
constructor(httpClient: HttpClient, defaultSchema?: string) {
55+
// Create postgrest client with custom fetch. `schema` sets the default
56+
// profile; postgrest-js attaches Accept-Profile/Content-Profile headers,
57+
// which the InsForge data API resolves to the target schema.
5658
this.postgrest = new PostgrestClient<any, any, any>('http://dummy', {
5759
fetch: createInsForgePostgrestFetch(httpClient),
5860
headers: {},
61+
...(defaultSchema ? { schema: defaultSchema } : {}),
5962
});
6063
}
6164

65+
/**
66+
* Select a non-default Postgres schema for the chained query. Maps to
67+
* PostgREST's `Accept-Profile` (reads) / `Content-Profile` (writes) header.
68+
* The schema must be exposed by the backend.
69+
*
70+
* @example
71+
* const { data } = await client.database
72+
* .schema('analytics')
73+
* .from('events')
74+
* .select('*');
75+
*
76+
* @example
77+
* await client.database.schema('analytics').rpc('rollup', { day: '2026-01-01' });
78+
*/
79+
schema(schemaName: string) {
80+
return this.postgrest.schema(schemaName);
81+
}
82+
6283
/**
6384
* Create a query builder for a table
6485
*

src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ export interface InsForgeConfig {
7171
detectOAuthCallback?: boolean;
7272
};
7373

74+
/**
75+
* Database module options.
76+
*/
77+
db?: {
78+
/**
79+
* Default Postgres schema for database queries. Maps to PostgREST's
80+
* `Accept-Profile`/`Content-Profile` headers. Override per-query with
81+
* `client.database.schema('other')`.
82+
* @default "public"
83+
*/
84+
schema?: string;
85+
};
86+
7487
/**
7588
* Custom headers to include with every request
7689
*/

0 commit comments

Comments
 (0)