@jxsuite/connector 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,130 @@
1
+ /**
2
+ * Bun-sqlite dialect — a minimal Kysely dialect over `bun:sqlite`.
3
+ *
4
+ * Backs the Sqlite connector and the dev server's `local: "sqlite"` stand-ins. The database opens
5
+ * lazily inside `Driver.init()` (creating the parent directory when a file path is given), so
6
+ * constructing the dialect stays synchronous and side-effect-free; tests and embedded hosts may
7
+ * inject an already-open database instead. `bun:sqlite` is imported dynamically through an opaque
8
+ * specifier so browser/worker bundles that merely reach this module's importers never try to
9
+ * resolve it.
10
+ */
11
+
12
+ import { CompiledQuery, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from "kysely";
13
+ import type { SQLQueryBindings } from "bun:sqlite";
14
+ import type { DatabaseConnection, Dialect, Driver, QueryResult } from "kysely";
15
+
16
+ /** The subset of `bun:sqlite`'s Statement the driver uses (type-only import — erased). */
17
+ export interface BunSqliteStatementLike {
18
+ columnNames: string[];
19
+ all: (...params: SQLQueryBindings[]) => unknown[];
20
+ run: (...params: SQLQueryBindings[]) => { changes: number; lastInsertRowid: number | bigint };
21
+ }
22
+
23
+ /** The subset of `bun:sqlite`'s Database the driver uses. */
24
+ export interface BunSqliteDatabaseLike {
25
+ prepare: (sql: string) => BunSqliteStatementLike;
26
+ close?: () => void;
27
+ }
28
+
29
+ export interface BunSqliteDialectConfig {
30
+ /** SQLite file path (":memory:" for in-memory). Ignored when `database` is injected. */
31
+ url?: string;
32
+ /** An already-open database — the dialect will not close it on destroy. */
33
+ database?: BunSqliteDatabaseLike;
34
+ }
35
+
36
+ /**
37
+ * Create the Kysely dialect.
38
+ *
39
+ * @param {BunSqliteDialectConfig} [config]
40
+ * @returns {Dialect}
41
+ */
42
+ export function createBunSqliteDialect(config: BunSqliteDialectConfig = {}): Dialect {
43
+ return {
44
+ createAdapter: () => new SqliteAdapter(),
45
+ createDriver: () => createDriver(config),
46
+ createIntrospector: (db) => new SqliteIntrospector(db),
47
+ createQueryCompiler: () => new SqliteQueryCompiler(),
48
+ };
49
+ }
50
+
51
+ /** Open the configured database (dynamic imports keep this module bundle-inert). */
52
+ async function openDatabase(config: BunSqliteDialectConfig): Promise<{
53
+ db: BunSqliteDatabaseLike;
54
+ owned: boolean;
55
+ }> {
56
+ const { database, url } = config;
57
+ if (database) {
58
+ return { db: database, owned: false };
59
+ }
60
+ const file = url ?? ":memory:";
61
+ if (file !== ":memory:") {
62
+ // Ensure the parent directory exists (dev-server convention: <project>/.jx/data/).
63
+ const fsSpecifier = "node:fs";
64
+ const pathSpecifier = "node:path";
65
+ const fs = (await import(fsSpecifier)) as {
66
+ mkdirSync: (path: string, options: { recursive: boolean }) => void;
67
+ };
68
+ const path = (await import(pathSpecifier)) as { dirname: (path: string) => string };
69
+ fs.mkdirSync(path.dirname(file), { recursive: true });
70
+ }
71
+ const sqliteSpecifier = "bun:sqlite";
72
+ const sqlite = (await import(sqliteSpecifier)) as {
73
+ Database: new (file: string) => BunSqliteDatabaseLike;
74
+ };
75
+ return { db: new sqlite.Database(file), owned: true };
76
+ }
77
+
78
+ /** Build the single-connection driver over one bun:sqlite database. */
79
+ function createDriver(config: BunSqliteDialectConfig): Driver {
80
+ let db: BunSqliteDatabaseLike | null = null;
81
+ let owned = false;
82
+
83
+ const connection: DatabaseConnection = {
84
+ executeQuery: async <R>(compiledQuery: CompiledQuery): Promise<QueryResult<R>> => {
85
+ if (!db) {
86
+ throw new Error("BunSqliteDialect used before init()");
87
+ }
88
+ const { sql, parameters } = compiledQuery;
89
+ const stmt = db.prepare(sql);
90
+ const bindings = parameters as SQLQueryBindings[];
91
+ if (stmt.columnNames.length > 0) {
92
+ return { rows: stmt.all(...bindings) as R[] };
93
+ }
94
+ const info = stmt.run(...bindings);
95
+ return {
96
+ insertId: BigInt(info.lastInsertRowid),
97
+ numAffectedRows: BigInt(info.changes),
98
+ rows: [],
99
+ };
100
+ },
101
+ streamQuery: () => {
102
+ throw new Error("BunSqliteDialect does not support streaming");
103
+ },
104
+ };
105
+
106
+ return {
107
+ acquireConnection: async () => connection,
108
+ beginTransaction: async (conn) => {
109
+ await conn.executeQuery(CompiledQuery.raw("begin"));
110
+ },
111
+ commitTransaction: async (conn) => {
112
+ await conn.executeQuery(CompiledQuery.raw("commit"));
113
+ },
114
+ destroy: async () => {
115
+ if (owned) {
116
+ db?.close?.();
117
+ }
118
+ db = null;
119
+ },
120
+ init: async () => {
121
+ ({ db, owned } = await openDatabase(config));
122
+ },
123
+ releaseConnection: async () => {
124
+ // Single-connection driver — nothing to release.
125
+ },
126
+ rollbackTransaction: async (conn) => {
127
+ await conn.executeQuery(CompiledQuery.raw("rollback"));
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * D1-http dialect — Kysely over Cloudflare's D1 HTTP API.
3
+ *
4
+ * Used where no D1 binding exists: `jx db push` from a developer machine, connection tests, and
5
+ * compiler-timing SSG bakes. Every query POSTs to the accounts/{account}/d1/database/{database}
6
+ * /query endpoint with a `CLOUDFLARE_API_TOKEN` bearer. Inside deployed Workers the kysely-d1
7
+ * binding dialect is used instead (see d1.ts).
8
+ */
9
+
10
+ import { SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from "kysely";
11
+ import type { CompiledQuery, DatabaseConnection, Dialect, Driver, QueryResult } from "kysely";
12
+
13
+ export interface D1HttpDialectConfig {
14
+ accountId: string;
15
+ databaseId: string;
16
+ apiToken: string;
17
+ /** Injected fetch (tests); defaults to globalThis.fetch. */
18
+ fetch?: typeof globalThis.fetch;
19
+ /** API origin override; defaults to https://api.cloudflare.com. */
20
+ baseUrl?: string;
21
+ }
22
+
23
+ interface D1HttpResponse {
24
+ success?: boolean;
25
+ errors?: { code?: number; message?: string }[];
26
+ result?: {
27
+ success?: boolean;
28
+ results?: Record<string, unknown>[];
29
+ meta?: { changes?: number; last_row_id?: number };
30
+ }[];
31
+ }
32
+
33
+ /**
34
+ * Create the Kysely dialect over the D1 HTTP API.
35
+ *
36
+ * @param {D1HttpDialectConfig} config
37
+ * @returns {Dialect}
38
+ */
39
+ export function createD1HttpDialect(config: D1HttpDialectConfig): Dialect {
40
+ return {
41
+ createAdapter: () => new SqliteAdapter(),
42
+ createDriver: () => createDriver(config),
43
+ createIntrospector: (db) => new SqliteIntrospector(db),
44
+ createQueryCompiler: () => new SqliteQueryCompiler(),
45
+ };
46
+ }
47
+
48
+ /** Execute one SQL statement against the HTTP endpoint. */
49
+ async function executeHttp<R>(
50
+ config: D1HttpDialectConfig,
51
+ sql: string,
52
+ parameters: readonly unknown[],
53
+ ): Promise<QueryResult<R>> {
54
+ const { accountId, databaseId, apiToken, baseUrl = "https://api.cloudflare.com" } = config;
55
+ const doFetch = config.fetch ?? globalThis.fetch;
56
+ const url = `${baseUrl}/client/v4/accounts/${accountId}/d1/database/${databaseId}/query`;
57
+ const response = await doFetch(url, {
58
+ body: JSON.stringify({ params: parameters, sql }),
59
+ headers: {
60
+ Authorization: `Bearer ${apiToken}`,
61
+ "Content-Type": "application/json",
62
+ },
63
+ method: "POST",
64
+ });
65
+ const body = (await response.json().catch(() => null)) as D1HttpResponse | null;
66
+ if (!response.ok || !body?.success) {
67
+ const detail =
68
+ body?.errors
69
+ ?.map((e) => e.message)
70
+ .filter(Boolean)
71
+ .join("; ") || `HTTP ${response.status}`;
72
+ throw new Error(`D1 HTTP API error: ${detail}`);
73
+ }
74
+ const [result] = body.result ?? [];
75
+ const meta = result?.meta ?? {};
76
+ return {
77
+ ...(meta.last_row_id === undefined ? {} : { insertId: BigInt(meta.last_row_id) }),
78
+ ...(meta.changes === undefined ? {} : { numAffectedRows: BigInt(meta.changes) }),
79
+ rows: (result?.results ?? []) as R[],
80
+ };
81
+ }
82
+
83
+ /** Build the stateless HTTP driver. */
84
+ function createDriver(config: D1HttpDialectConfig): Driver {
85
+ const connection: DatabaseConnection = {
86
+ executeQuery: <R>(compiledQuery: CompiledQuery): Promise<QueryResult<R>> =>
87
+ executeHttp<R>(config, compiledQuery.sql, compiledQuery.parameters),
88
+ streamQuery: () => {
89
+ throw new Error("D1HttpDialect does not support streaming");
90
+ },
91
+ };
92
+ return {
93
+ acquireConnection: async () => connection,
94
+ beginTransaction: async () => {
95
+ // The HTTP API is per-statement; transactions are not supported.
96
+ },
97
+ commitTransaction: async () => {
98
+ // See beginTransaction.
99
+ },
100
+ destroy: async () => {
101
+ // Stateless — nothing to close.
102
+ },
103
+ init: async () => {
104
+ // Stateless — nothing to open.
105
+ },
106
+ releaseConnection: async () => {
107
+ // Stateless — nothing to release.
108
+ },
109
+ rollbackTransaction: async () => {
110
+ throw new Error("D1HttpDialect cannot roll back (no transaction support)");
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Provider-utils — shared machinery behind the connector capability methods.
3
+ *
4
+ * Each provider (d1, supabase, sqlite) resolves a Kysely dialect its own way; deploySchema and
5
+ * testConnection are then identical: open Kysely over the dialect, run the additive sync (or a
6
+ * probe select), and always destroy the instance. Kept separate from connectors.ts so the provider
7
+ * modules can share it without an import cycle.
8
+ */
9
+
10
+ import { Kysely, sql } from "kysely";
11
+ import { syncTables } from "./ddl.ts";
12
+ import type { Dialect } from "kysely";
13
+ import type { SqlDialectKind } from "./columns.ts";
14
+ import type { DynamicDatabase } from "./query.ts";
15
+ import type { ConnectionDef, DeploySchemaResult, TableDef, TestConnectionResult } from "./types.ts";
16
+
17
+ /** The static capability surface of a connector provider implementation. */
18
+ export interface ConnectorProvider {
19
+ kind: SqlDialectKind;
20
+ dialect: (connection: ConnectionDef, env: Record<string, unknown>) => Dialect | Promise<Dialect>;
21
+ deploySchema: (
22
+ tables: Record<string, TableDef>,
23
+ connection: ConnectionDef,
24
+ options: { env: Record<string, unknown>; dryRun?: boolean },
25
+ ) => Promise<DeploySchemaResult>;
26
+ bindings: (connection: ConnectionDef) => Record<string, unknown>;
27
+ testConnection: (
28
+ connection: ConnectionDef,
29
+ env: Record<string, unknown>,
30
+ ) => Promise<TestConnectionResult>;
31
+ }
32
+
33
+ /**
34
+ * Run the additive schema sync over a dialect, always destroying the Kysely instance.
35
+ *
36
+ * @param {Dialect} dialect
37
+ * @param {SqlDialectKind} kind
38
+ * @param {Record<string, TableDef>} tables
39
+ * @param {{ dryRun?: boolean }} [options]
40
+ * @returns {Promise<DeploySchemaResult>}
41
+ */
42
+ export async function deployWithDialect(
43
+ dialect: Dialect,
44
+ kind: SqlDialectKind,
45
+ tables: Record<string, TableDef>,
46
+ options: { dryRun?: boolean } = {},
47
+ ): Promise<DeploySchemaResult> {
48
+ const db = new Kysely<DynamicDatabase>({ dialect });
49
+ try {
50
+ return await syncTables(db, tables, { dialect: kind, dryRun: options.dryRun ?? false });
51
+ } finally {
52
+ await db.destroy();
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Probe a dialect with `select 1`.
58
+ *
59
+ * @param {Dialect} dialect
60
+ * @returns {Promise<TestConnectionResult>}
61
+ */
62
+ export async function testWithDialect(dialect: Dialect): Promise<TestConnectionResult> {
63
+ const db = new Kysely<DynamicDatabase>({ dialect });
64
+ try {
65
+ await sql`select 1`.execute(db);
66
+ return { ok: true };
67
+ } catch (error) {
68
+ return { error: error instanceof Error ? error.message : String(error), ok: false };
69
+ } finally {
70
+ await db.destroy();
71
+ }
72
+ }
73
+
74
+ /** The tables of one connection (a data section filtered by connection name). */
75
+ export function tablesForConnection(
76
+ tables: Record<string, TableDef>,
77
+ connectionName: string,
78
+ ): Record<string, TableDef> {
79
+ const out: Record<string, TableDef> = {};
80
+ for (const [name, def] of Object.entries(tables)) {
81
+ if (def.connection === connectionName) {
82
+ out[name] = def;
83
+ }
84
+ }
85
+ return out;
86
+ }
package/src/query.ts ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Query — the content filter grammar over Kysely.
3
+ *
4
+ * Dynamic tables answer the same filter/sort/limit grammar content collections use
5
+ * (`@jxsuite/parser/content`): a filter is an array of `{ field, op, value }` rules (or an object
6
+ * shorthand of equality pairs), sort is `{ field, order }` (or a list), and every rule must match
7
+ * (AND). This module translates those rules into `where()` expressions with the same semantics the
8
+ * in-memory evaluator has — including `!=` matching NULL and `empty` treating '' and '[]' as
9
+ * empty.
10
+ */
11
+
12
+ import type { ExpressionBuilder, ExpressionWrapper, SelectQueryBuilder, SqlBool } from "kysely";
13
+ import type { ColumnSpec } from "./columns.ts";
14
+
15
+ /** One filter rule of the shared content grammar. */
16
+ export interface FilterRule {
17
+ field: string;
18
+ op: string;
19
+ value?: unknown;
20
+ }
21
+
22
+ export interface SortRule {
23
+ field: string;
24
+ order?: string;
25
+ }
26
+
27
+ /** Loosely-typed database shape for dynamic tables (columns are only known at runtime). */
28
+ export type DynamicDatabase = Record<string, Record<string, unknown>>;
29
+
30
+ type DynamicSelect = SelectQueryBuilder<DynamicDatabase, string, Record<string, unknown>>;
31
+ type DynamicEb = ExpressionBuilder<DynamicDatabase, string>;
32
+ type BoolExpr = ExpressionWrapper<DynamicDatabase, string, SqlBool>;
33
+
34
+ /**
35
+ * Normalize a wire filter (rule array or equality-object shorthand) into rules.
36
+ *
37
+ * @param {unknown} filter
38
+ * @returns {FilterRule[]}
39
+ */
40
+ export function normalizeFilter(filter: unknown): FilterRule[] {
41
+ if (Array.isArray(filter)) {
42
+ return (filter as FilterRule[]).filter(
43
+ (rule) => rule && typeof rule === "object" && typeof rule.field === "string",
44
+ );
45
+ }
46
+ if (filter && typeof filter === "object") {
47
+ return Object.entries(filter as Record<string, unknown>).map(([field, value]) => ({
48
+ field,
49
+ op: "==",
50
+ value,
51
+ }));
52
+ }
53
+ return [];
54
+ }
55
+
56
+ /** Normalize a wire sort (single rule or list) into rules. */
57
+ export function normalizeSort(sort: unknown): SortRule[] {
58
+ const list = Array.isArray(sort) ? sort : sort ? [sort] : [];
59
+ return (list as SortRule[]).filter(
60
+ (rule) => rule && typeof rule === "object" && typeof rule.field === "string",
61
+ );
62
+ }
63
+
64
+ /** Resolve a grammar field name to its physical column (`author` → `author_id` for refs). */
65
+ export function columnForField(field: string, specs: ColumnSpec[]): string {
66
+ if (field === "id") {
67
+ return "id";
68
+ }
69
+ const spec = specs.find((s) => s.field === field || s.column === field);
70
+ return spec?.column ?? field;
71
+ }
72
+
73
+ /**
74
+ * Apply filter rules to a select builder (AND semantics, matching the in-memory evaluator).
75
+ *
76
+ * @param {DynamicSelect} qb
77
+ * @param {FilterRule[]} rules
78
+ * @param {ColumnSpec[]} specs - Column plan (field → column mapping, storage classes)
79
+ * @returns {DynamicSelect}
80
+ */
81
+ export function applyFilter(
82
+ qb: DynamicSelect,
83
+ rules: FilterRule[],
84
+ specs: ColumnSpec[],
85
+ ): DynamicSelect {
86
+ let out = qb;
87
+ for (const rule of rules) {
88
+ const column = columnForField(rule.field, specs);
89
+ const spec = specs.find((s) => s.column === column) ?? null;
90
+ out = out.where((eb) => ruleExpression(eb, rule, column, spec));
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Translate one filter rule into a boolean expression.
97
+ *
98
+ * @param {DynamicEb} eb
99
+ * @param {FilterRule} rule
100
+ * @param {string} column - Physical column name
101
+ * @param {ColumnSpec | null} spec - Column spec when known (storage-aware `contains`)
102
+ * @returns {BoolExpr}
103
+ */
104
+ function ruleExpression(
105
+ eb: DynamicEb,
106
+ rule: FilterRule,
107
+ column: string,
108
+ spec: ColumnSpec | null,
109
+ ): BoolExpr {
110
+ const { op, value } = rule;
111
+ switch (op) {
112
+ case "==": {
113
+ return value === null || value === undefined
114
+ ? eb(column, "is", null)
115
+ : eb(column, "=", storageValue(spec, value));
116
+ }
117
+ case "!=": {
118
+ // NULL never equals the probe in the in-memory grammar, so NULL rows match `!=`.
119
+ return value === null || value === undefined
120
+ ? eb(column, "is not", null)
121
+ : eb.or([eb(column, "!=", storageValue(spec, value)), eb(column, "is", null)]);
122
+ }
123
+ case "empty": {
124
+ return eb.or([eb(column, "is", null), eb(column, "=", ""), eb(column, "=", "[]")]);
125
+ }
126
+ case "not empty": {
127
+ return eb.and([eb(column, "is not", null), eb(column, "!=", ""), eb(column, "!=", "[]")]);
128
+ }
129
+ case "contains": {
130
+ return eb(column, "like", containsPattern(spec, value));
131
+ }
132
+ case "not contains": {
133
+ return eb.or([eb(column, "not like", containsPattern(spec, value)), eb(column, "is", null)]);
134
+ }
135
+ case ">":
136
+ case "<":
137
+ case ">=":
138
+ case "<=": {
139
+ return eb(column, op, Number(value));
140
+ }
141
+ default: {
142
+ // Unknown ops match everything, like the in-memory evaluator.
143
+ return eb.lit(true as SqlBool);
144
+ }
145
+ }
146
+ }
147
+
148
+ /** LIKE pattern for `contains`: JSON columns match the JSON-encoded element. */
149
+ function containsPattern(spec: ColumnSpec | null, value: unknown): string {
150
+ if (spec?.storage === "json") {
151
+ const encoded = JSON.stringify(value);
152
+ return `%${escapeLike(encoded)}%`;
153
+ }
154
+ return `%${escapeLike(String(value ?? ""))}%`;
155
+ }
156
+
157
+ /** Escape LIKE wildcards in a literal probe. */
158
+ function escapeLike(text: string): string {
159
+ return text.replaceAll("%", String.raw`\%`).replaceAll("_", String.raw`\_`);
160
+ }
161
+
162
+ /** Coerce a filter probe value to the column's storage representation. */
163
+ function storageValue(spec: ColumnSpec | null, value: unknown): string | number {
164
+ if (spec?.storage === "boolean") {
165
+ const truthy = value === true || value === "true" || value === 1 || value === "1";
166
+ return truthy ? 1 : 0;
167
+ }
168
+ if (spec?.storage === "json") {
169
+ return JSON.stringify(value);
170
+ }
171
+ if (typeof value === "number") {
172
+ return value;
173
+ }
174
+ if (typeof value === "boolean") {
175
+ return value ? 1 : 0;
176
+ }
177
+ return String(value);
178
+ }
179
+
180
+ /**
181
+ * Apply sort rules to a select builder.
182
+ *
183
+ * @param {DynamicSelect} qb
184
+ * @param {SortRule[]} rules
185
+ * @param {ColumnSpec[]} specs
186
+ * @returns {DynamicSelect}
187
+ */
188
+ export function applySort(
189
+ qb: DynamicSelect,
190
+ rules: SortRule[],
191
+ specs: ColumnSpec[],
192
+ ): DynamicSelect {
193
+ let out = qb;
194
+ for (const rule of rules) {
195
+ const column = columnForField(rule.field, specs);
196
+ out = out.orderBy(column, rule.order === "desc" ? "desc" : "asc");
197
+ }
198
+ return out;
199
+ }
package/src/sqlite.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Sqlite provider — file-backed SQLite over bun:sqlite.
3
+ *
4
+ * The dev-server default and the `local: "sqlite"` stand-in target: connections resolve to
5
+ * `<root>/.jx/data/<name>.sqlite` (or the connection's `file` path) under the project root the host
6
+ * publishes as `env.JX_PROJECT_ROOT`. Node/Bun hosts only — deployed Cloudflare sites use D1 or
7
+ * Supabase.
8
+ */
9
+
10
+ import { createBunSqliteDialect } from "./dialects/bun-sqlite.ts";
11
+ import { deployWithDialect, testWithDialect } from "./provider-utils.ts";
12
+ import type { Dialect } from "kysely";
13
+ import type { ConnectorProvider } from "./provider-utils.ts";
14
+ import type { ConnectionDef } from "./types.ts";
15
+
16
+ /** Resolve the SQLite file path for a connection. */
17
+ export function sqliteFilePath(connection: ConnectionDef, env: Record<string, unknown>): string {
18
+ const root = typeof env["JX_PROJECT_ROOT"] === "string" ? env["JX_PROJECT_ROOT"] : ".";
19
+ const name = connection.$name ?? "default";
20
+ const file = connection.file ?? `./.jx/data/${name}.sqlite`;
21
+ if (file === ":memory:" || file.startsWith("/")) {
22
+ return file;
23
+ }
24
+ return `${root.replace(/\/$/, "")}/${file.replace(/^\.\//, "")}`;
25
+ }
26
+
27
+ /** Build the bun:sqlite dialect for a connection. */
28
+ function sqliteDialect(connection: ConnectionDef, env: Record<string, unknown>): Dialect {
29
+ return createBunSqliteDialect({ url: sqliteFilePath(connection, env) });
30
+ }
31
+
32
+ export const Sqlite = {
33
+ bindings: () => ({}),
34
+ deploySchema: async (
35
+ tables: Parameters<ConnectorProvider["deploySchema"]>[0],
36
+ connection: ConnectionDef,
37
+ options: { env: Record<string, unknown>; dryRun?: boolean },
38
+ ) => {
39
+ const dialect = sqliteDialect(connection, options.env);
40
+ return deployWithDialect(dialect, "sqlite", tables, { dryRun: options.dryRun ?? false });
41
+ },
42
+ dialect: sqliteDialect,
43
+ kind: "sqlite",
44
+ testConnection: async (connection: ConnectionDef, env: Record<string, unknown>) => {
45
+ try {
46
+ return await testWithDialect(sqliteDialect(connection, env));
47
+ } catch (error) {
48
+ return { error: error instanceof Error ? error.message : String(error), ok: false };
49
+ }
50
+ },
51
+ } satisfies ConnectorProvider;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Supabase provider — postgres connections over postgres-js.
3
+ *
4
+ * The connection URL is never committed: `urlEnv` names the env var carrying it (.dev.vars locally,
5
+ * a platform secret in production). From Workers, a Hyperdrive binding (named by `binding`,
6
+ * provisioned from `hyperdriveId`) takes precedence — its `connectionString` fronts the database
7
+ * with connection pooling. postgres-js and the kysely-postgres-js dialect load lazily so hosts that
8
+ * never touch a postgres connection never import them.
9
+ */
10
+
11
+ import { deployWithDialect, testWithDialect } from "./provider-utils.ts";
12
+ import type { Dialect } from "kysely";
13
+ import type { ConnectorProvider } from "./provider-utils.ts";
14
+ import type { ConnectionDef } from "./types.ts";
15
+
16
+ /**
17
+ * Resolve the postgres URL for a connection: Hyperdrive binding first, then `urlEnv`.
18
+ *
19
+ * @param {ConnectionDef} connection
20
+ * @param {Record<string, unknown>} env
21
+ * @returns {string}
22
+ */
23
+ export function resolvePostgresUrl(
24
+ connection: ConnectionDef,
25
+ env: Record<string, unknown>,
26
+ ): string {
27
+ const { binding, urlEnv, $name = "connection" } = connection;
28
+ const bound = binding ? (env[binding] as { connectionString?: string } | undefined) : undefined;
29
+ if (bound && typeof bound.connectionString === "string") {
30
+ return bound.connectionString;
31
+ }
32
+ const url = urlEnv ? env[urlEnv] : undefined;
33
+ if (typeof url === "string" && url !== "") {
34
+ return url;
35
+ }
36
+ throw new Error(
37
+ `Supabase connection "${$name}" is unreachable: no "${binding ?? "<binding>"}" Hyperdrive ` +
38
+ `binding on env and ${urlEnv ? `env.${urlEnv} is unset` : `no "urlEnv" is configured`}`,
39
+ );
40
+ }
41
+
42
+ /** Build the postgres-js dialect for a URL (lazy imports keep non-postgres hosts lean). */
43
+ async function postgresDialect(url: string): Promise<Dialect> {
44
+ const { default: postgres } = await import("postgres");
45
+ const { PostgresJSDialect } = await import("kysely-postgres-js");
46
+ // Prepare: false — Supabase's transaction-mode pooler does not support prepared statements.
47
+ return new PostgresJSDialect({ postgres: postgres(url, { prepare: false }) });
48
+ }
49
+
50
+ export const Supabase = {
51
+ bindings: (connection: ConnectionDef) =>
52
+ connection.hyperdriveId
53
+ ? {
54
+ hyperdrive: [
55
+ {
56
+ binding: connection.binding ?? "HYPERDRIVE",
57
+ id: connection.hyperdriveId,
58
+ },
59
+ ],
60
+ }
61
+ : {},
62
+ deploySchema: async (
63
+ tables: Parameters<ConnectorProvider["deploySchema"]>[0],
64
+ connection: ConnectionDef,
65
+ options: { env: Record<string, unknown>; dryRun?: boolean },
66
+ ) => {
67
+ const dialect = await postgresDialect(resolvePostgresUrl(connection, options.env));
68
+ return deployWithDialect(dialect, "postgres", tables, { dryRun: options.dryRun ?? false });
69
+ },
70
+ dialect: (connection: ConnectionDef, env: Record<string, unknown>) =>
71
+ postgresDialect(resolvePostgresUrl(connection, env)),
72
+ kind: "postgres",
73
+ testConnection: async (connection: ConnectionDef, env: Record<string, unknown>) => {
74
+ try {
75
+ const dialect = await postgresDialect(resolvePostgresUrl(connection, env));
76
+ return await testWithDialect(dialect);
77
+ } catch (error) {
78
+ return { error: error instanceof Error ? error.message : String(error), ok: false };
79
+ }
80
+ },
81
+ } satisfies ConnectorProvider;