@jxsuite/connector 0.6.2 → 0.6.3
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.
- package/package.json +3 -3
- package/src/columns.ts +28 -21
- package/src/connectors.ts +19 -8
- package/src/d1.ts +18 -14
- package/src/ddl.ts +8 -14
- package/src/dialects/bun-sqlite.ts +11 -0
- package/src/dialects/d1-http.ts +22 -6
- package/src/provider-utils.ts +28 -14
- package/src/query.ts +40 -23
- package/src/sqlite.ts +8 -6
- package/src/supabase.ts +10 -12
- package/src/table-actions.ts +34 -20
- package/src/table-delete.ts +2 -1
- package/src/table-insert.ts +2 -1
- package/src/table-node.ts +22 -21
- package/src/table-shared.ts +22 -18
- package/src/table-state.ts +33 -16
- package/src/table-update.ts +2 -1
- package/src/types.ts +30 -23
- package/src/validate.ts +25 -26
- package/src/worker.ts +128 -76
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/connector",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Jx database connections and dynamic data tables served over /_jx/data",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -57,14 +57,14 @@
|
|
|
57
57
|
"upgrade": "bun update --latest && bun install"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@jxsuite/schema": "^
|
|
60
|
+
"@jxsuite/schema": "^4.0.0",
|
|
61
61
|
"kysely": "^0.29.6",
|
|
62
62
|
"kysely-d1": "^0.4.0",
|
|
63
63
|
"kysely-postgres-js": "^5.0.1",
|
|
64
64
|
"postgres": "^3.4.9"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@cloudflare/workers-types": "^5.
|
|
67
|
+
"@cloudflare/workers-types": "^5.20261016.1",
|
|
68
68
|
"ajv": "^8.20.0",
|
|
69
69
|
"hono": "^4.13.13"
|
|
70
70
|
},
|
package/src/columns.ts
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
* - To-many reference to any other section → JSON text column of id strings
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { isBoolean, isString } from "@jxsuite/schema/guards";
|
|
21
|
+
import type { ColumnDataType } from "kysely";
|
|
22
|
+
import type { JsonValue } from "@jxsuite/schema/types";
|
|
23
|
+
import type { DynamicRow } from "./query.ts";
|
|
20
24
|
import type { TableDef } from "./types.ts";
|
|
21
25
|
|
|
22
26
|
export type SqlDialectKind = "sqlite" | "postgres";
|
|
@@ -28,8 +32,8 @@ export interface RefTarget {
|
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
/** Parse a relationship ref pointer, or null when the string is not one. */
|
|
31
|
-
export function parseRefPointer(pointer:
|
|
32
|
-
if (
|
|
35
|
+
export function parseRefPointer(pointer: JsonValue | undefined): RefTarget | null {
|
|
36
|
+
if (!isString(pointer)) {
|
|
33
37
|
return null;
|
|
34
38
|
}
|
|
35
39
|
const match = pointer.match(/^#\/([A-Za-z][\w-]*)\/([\w.-]+)$/);
|
|
@@ -49,7 +53,7 @@ export interface ColumnSpec {
|
|
|
49
53
|
/** Physical column name (`<field>_id` for to-one references). */
|
|
50
54
|
column: string;
|
|
51
55
|
/** SQL data type passed to the Kysely schema builder. */
|
|
52
|
-
dataType:
|
|
56
|
+
dataType: ColumnDataType;
|
|
53
57
|
storage: ColumnStorage;
|
|
54
58
|
/** Set for reference fields (to-one FK columns and to-many JSON id lists). */
|
|
55
59
|
ref: RefTarget | null;
|
|
@@ -69,8 +73,8 @@ export interface JunctionSpec {
|
|
|
69
73
|
sourceColumn: string;
|
|
70
74
|
/** `${targetTable}_id`, suffixed `_2` when the reference is self-referential. */
|
|
71
75
|
targetColumn: string;
|
|
72
|
-
sourceIdType:
|
|
73
|
-
targetIdType:
|
|
76
|
+
sourceIdType: ColumnDataType;
|
|
77
|
+
targetIdType: ColumnDataType;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
/** The full physical plan for one table. */
|
|
@@ -84,12 +88,12 @@ export interface TablePlan {
|
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
/** SQL data type of a table's primary key. */
|
|
87
|
-
export function idDataType(idKind: "uuid" | "integer"):
|
|
91
|
+
export function idDataType(idKind: "uuid" | "integer"): ColumnDataType {
|
|
88
92
|
return idKind === "uuid" ? "text" : "integer";
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
/** Map a scalar field schema type to the SQL data type for a dialect. */
|
|
92
|
-
function scalarDataType(type: string | undefined, dialect: SqlDialectKind):
|
|
96
|
+
function scalarDataType(type: string | undefined, dialect: SqlDialectKind): ColumnDataType {
|
|
93
97
|
switch (type) {
|
|
94
98
|
case "number": {
|
|
95
99
|
return dialect === "postgres" ? "double precision" : "real";
|
|
@@ -240,11 +244,15 @@ export function newRowId(idKind: "uuid" | "integer"): string | undefined {
|
|
|
240
244
|
* Coerce one JSON payload value to its SQL storage representation.
|
|
241
245
|
*
|
|
242
246
|
* @param {ColumnSpec} spec
|
|
243
|
-
* @param {
|
|
247
|
+
* @param {JsonValue | undefined} value - Already-validated payload value
|
|
244
248
|
* @param {SqlDialectKind} dialect
|
|
245
|
-
* @returns {
|
|
249
|
+
* @returns {JsonValue}
|
|
246
250
|
*/
|
|
247
|
-
export function toStorage(
|
|
251
|
+
export function toStorage(
|
|
252
|
+
spec: ColumnSpec,
|
|
253
|
+
value: JsonValue | undefined,
|
|
254
|
+
dialect: SqlDialectKind,
|
|
255
|
+
): JsonValue {
|
|
248
256
|
if (value === null || value === undefined) {
|
|
249
257
|
return null;
|
|
250
258
|
}
|
|
@@ -265,24 +273,23 @@ export function toStorage(spec: ColumnSpec, value: unknown, dialect: SqlDialectK
|
|
|
265
273
|
* Coerce a database row back to its JSON payload shape (booleans, JSON columns).
|
|
266
274
|
*
|
|
267
275
|
* @param {ColumnSpec[]} specs - Column plan of the table
|
|
268
|
-
* @param {
|
|
269
|
-
* @returns {
|
|
276
|
+
* @param {DynamicRow} row - Raw row from the driver
|
|
277
|
+
* @returns {DynamicRow}
|
|
270
278
|
*/
|
|
271
|
-
export function fromStorage(
|
|
272
|
-
|
|
273
|
-
row: Record<string, unknown>,
|
|
274
|
-
): Record<string, unknown> {
|
|
275
|
-
const out: Record<string, unknown> = { ...row };
|
|
279
|
+
export function fromStorage(specs: ColumnSpec[], row: DynamicRow) {
|
|
280
|
+
const out = { ...row };
|
|
276
281
|
for (const spec of specs) {
|
|
277
282
|
const value = out[spec.column];
|
|
278
283
|
if (value === null || value === undefined) {
|
|
279
284
|
continue;
|
|
280
285
|
}
|
|
281
|
-
if (spec.storage === "boolean" &&
|
|
282
|
-
out[spec.column] = value === 1 || value === "1"
|
|
283
|
-
} else if (spec.storage === "json" &&
|
|
286
|
+
if (spec.storage === "boolean" && !isBoolean(value)) {
|
|
287
|
+
out[spec.column] = value === 1 || value === "1";
|
|
288
|
+
} else if (spec.storage === "json" && isString(value)) {
|
|
284
289
|
try {
|
|
285
|
-
|
|
290
|
+
/* SAFETY: JSON.parse only ever yields JSON values (null, booleans, numbers, strings, arrays
|
|
291
|
+
and plain objects); a malformed string throws instead and is handled below. */
|
|
292
|
+
out[spec.column] = JSON.parse(value) as JsonValue;
|
|
286
293
|
} catch {
|
|
287
294
|
// Malformed stored JSON stays a string rather than failing the read.
|
|
288
295
|
}
|
package/src/connectors.ts
CHANGED
|
@@ -13,26 +13,35 @@ import { Sqlite } from "./sqlite.ts";
|
|
|
13
13
|
import { Supabase } from "./supabase.ts";
|
|
14
14
|
import type { Dialect } from "kysely";
|
|
15
15
|
import type { SqlDialectKind } from "./columns.ts";
|
|
16
|
-
import type { ConnectorProvider } from "./provider-utils.ts";
|
|
16
|
+
import type { ConnectorProvider, HostEnv } from "./provider-utils.ts";
|
|
17
17
|
import type { ConnectionDef } from "./types.ts";
|
|
18
18
|
|
|
19
|
-
export type {
|
|
19
|
+
export type {
|
|
20
|
+
ConnectorProvider,
|
|
21
|
+
HostEnv,
|
|
22
|
+
HostEnvValue,
|
|
23
|
+
HyperdriveBinding,
|
|
24
|
+
} from "./provider-utils.ts";
|
|
20
25
|
export { tablesForConnection } from "./provider-utils.ts";
|
|
21
26
|
export { D1 } from "./d1.ts";
|
|
22
27
|
export { Sqlite } from "./sqlite.ts";
|
|
23
28
|
export { Supabase } from "./supabase.ts";
|
|
24
29
|
|
|
25
30
|
/** First-party providers keyed by their `connector.provider` id. */
|
|
26
|
-
export const PROVIDERS
|
|
31
|
+
export const PROVIDERS = {
|
|
27
32
|
d1: D1,
|
|
28
33
|
sqlite: Sqlite,
|
|
29
34
|
supabase: Supabase,
|
|
30
|
-
}
|
|
35
|
+
} satisfies Record<string, ConnectorProvider>;
|
|
36
|
+
|
|
37
|
+
/** True when `id` names one of the first-party providers (own keys only, never prototype members). */
|
|
38
|
+
function isFirstPartyProvider(id: string): id is keyof typeof PROVIDERS {
|
|
39
|
+
return Object.hasOwn(PROVIDERS, id);
|
|
40
|
+
}
|
|
31
41
|
|
|
32
42
|
/** The `connections`-bearing slice of a project config. */
|
|
33
43
|
export interface ConnectionsConfig {
|
|
34
44
|
connections?: Record<string, ConnectionDef>;
|
|
35
|
-
[key: string]: unknown;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
/**
|
|
@@ -40,20 +49,22 @@ export interface ConnectionsConfig {
|
|
|
40
49
|
*
|
|
41
50
|
* @param {string} connectionName - Key of the project.json `connections` section
|
|
42
51
|
* @param {ConnectionsConfig} projectConfig - The (partial) project config
|
|
43
|
-
* @param {
|
|
52
|
+
* @param {HostEnv} env - Host environment (bindings + env vars)
|
|
44
53
|
* @returns {Promise<{ dialect: Dialect; type: SqlDialectKind }>}
|
|
45
54
|
*/
|
|
46
55
|
export async function resolveDialect(
|
|
47
56
|
connectionName: string,
|
|
48
57
|
projectConfig: ConnectionsConfig,
|
|
49
|
-
env:
|
|
58
|
+
env: HostEnv,
|
|
50
59
|
): Promise<{ dialect: Dialect; type: SqlDialectKind }> {
|
|
51
60
|
const connections = projectConfig.connections ?? {};
|
|
52
61
|
const connection = connections[connectionName];
|
|
53
62
|
if (!connection) {
|
|
54
63
|
throw new Error(`Unknown connection "${connectionName}" (no such connections entry)`);
|
|
55
64
|
}
|
|
56
|
-
const provider =
|
|
65
|
+
const provider = isFirstPartyProvider(connection.provider)
|
|
66
|
+
? PROVIDERS[connection.provider]
|
|
67
|
+
: undefined;
|
|
57
68
|
if (!provider) {
|
|
58
69
|
throw new Error(
|
|
59
70
|
`Connection "${connectionName}" names unknown provider "${connection.provider}" ` +
|
package/src/d1.ts
CHANGED
|
@@ -8,24 +8,29 @@
|
|
|
8
8
|
* value (specs/extensions.md §13).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { isString } from "@jxsuite/schema/guards";
|
|
11
12
|
import { D1Dialect } from "kysely-d1";
|
|
12
13
|
import { createD1HttpDialect } from "./dialects/d1-http.ts";
|
|
13
14
|
import { deployWithDialect, testWithDialect } from "./provider-utils.ts";
|
|
14
15
|
import type { D1Database } from "@cloudflare/workers-types";
|
|
15
16
|
import type { Dialect } from "kysely";
|
|
16
|
-
import type {
|
|
17
|
+
import type { JsonObject } from "@jxsuite/schema/types";
|
|
18
|
+
import type { ConnectorProvider, HostEnv } from "./provider-utils.ts";
|
|
17
19
|
import type { ConnectionDef } from "./types.ts";
|
|
18
20
|
|
|
19
21
|
/** Resolve a D1 dialect for a connection (binding first, HTTP API fallback). */
|
|
20
|
-
function d1Dialect(connection: ConnectionDef, env:
|
|
22
|
+
function d1Dialect(connection: ConnectionDef, env: HostEnv): Dialect {
|
|
21
23
|
const { binding, databaseId, $name = "connection" } = connection;
|
|
22
24
|
const bound = binding ? env[binding] : undefined;
|
|
23
25
|
if (bound) {
|
|
26
|
+
/* SAFETY: a d1 connection's `binding` names the D1 database binding its bindings() fragment
|
|
27
|
+
provisions (`d1_databases[].binding`), so the env entry under that name is the D1Database
|
|
28
|
+
the Workers runtime injects; kysely-d1 receives it unchanged. */
|
|
24
29
|
return new D1Dialect({ database: bound as D1Database });
|
|
25
30
|
}
|
|
26
31
|
const apiToken = env["CLOUDFLARE_API_TOKEN"];
|
|
27
32
|
const accountId = connection.accountId ?? env["CLOUDFLARE_ACCOUNT_ID"];
|
|
28
|
-
if (
|
|
33
|
+
if (isString(apiToken) && isString(accountId) && databaseId) {
|
|
29
34
|
return createD1HttpDialect({ accountId, apiToken, databaseId });
|
|
30
35
|
}
|
|
31
36
|
throw new Error(
|
|
@@ -35,26 +40,25 @@ function d1Dialect(connection: ConnectionDef, env: Record<string, unknown>): Dia
|
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
export const D1 = {
|
|
38
|
-
bindings: (connection: ConnectionDef) =>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}),
|
|
43
|
+
bindings: (connection: ConnectionDef) => {
|
|
44
|
+
const database: JsonObject = { binding: connection.binding ?? "DB" };
|
|
45
|
+
if (connection.databaseId !== undefined) {
|
|
46
|
+
database.database_id = connection.databaseId;
|
|
47
|
+
}
|
|
48
|
+
database.database_name = connection.$name ?? connection.binding ?? "DB";
|
|
49
|
+
return { d1_databases: [database] };
|
|
50
|
+
},
|
|
47
51
|
deploySchema: async (
|
|
48
52
|
tables: Parameters<ConnectorProvider["deploySchema"]>[0],
|
|
49
53
|
connection: ConnectionDef,
|
|
50
|
-
options: { env:
|
|
54
|
+
options: { env: HostEnv; dryRun?: boolean },
|
|
51
55
|
) => {
|
|
52
56
|
const dialect = d1Dialect(connection, options.env);
|
|
53
57
|
return deployWithDialect(dialect, "sqlite", tables, { dryRun: options.dryRun ?? false });
|
|
54
58
|
},
|
|
55
59
|
dialect: d1Dialect,
|
|
56
60
|
kind: "sqlite",
|
|
57
|
-
testConnection: async (connection: ConnectionDef, env:
|
|
61
|
+
testConnection: async (connection: ConnectionDef, env: HostEnv) => {
|
|
58
62
|
try {
|
|
59
63
|
return await testWithDialect(d1Dialect(connection, env));
|
|
60
64
|
} catch (error) {
|
package/src/ddl.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { sql } from "kysely";
|
|
13
13
|
import { idDataType, planTable } from "./columns.ts";
|
|
14
|
-
import type {
|
|
14
|
+
import type { CreateTableBuilder, Kysely } from "kysely";
|
|
15
15
|
import type { SqlDialectKind, TablePlan } from "./columns.ts";
|
|
16
16
|
import type { DynamicDatabase } from "./query.ts";
|
|
17
17
|
import type { DeploySchemaResult, TableDef } from "./types.ts";
|
|
@@ -27,7 +27,7 @@ const SYSTEM_COLUMNS = new Set(["id", "created_at", "updated_at"]);
|
|
|
27
27
|
|
|
28
28
|
interface Executable {
|
|
29
29
|
compile: () => { sql: string };
|
|
30
|
-
execute: () => Promise<
|
|
30
|
+
execute: () => Promise<void>;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -77,9 +77,7 @@ export async function syncTables(
|
|
|
77
77
|
for (const col of plan.columns) {
|
|
78
78
|
const current = byName.get(col.column);
|
|
79
79
|
if (!current) {
|
|
80
|
-
await run(
|
|
81
|
-
db.schema.alterTable(plan.table).addColumn(col.column, col.dataType as ColumnDataType),
|
|
82
|
-
);
|
|
80
|
+
await run(db.schema.alterTable(plan.table).addColumn(col.column, col.dataType));
|
|
83
81
|
continue;
|
|
84
82
|
}
|
|
85
83
|
if (current.dataType.toLowerCase() !== col.dataType.toLowerCase()) {
|
|
@@ -112,12 +110,8 @@ export async function syncTables(
|
|
|
112
110
|
await run(
|
|
113
111
|
db.schema
|
|
114
112
|
.createTable(junction.table)
|
|
115
|
-
.addColumn(junction.sourceColumn, junction.sourceIdType
|
|
116
|
-
|
|
117
|
-
)
|
|
118
|
-
.addColumn(junction.targetColumn, junction.targetIdType as ColumnDataType, (c) =>
|
|
119
|
-
c.notNull(),
|
|
120
|
-
)
|
|
113
|
+
.addColumn(junction.sourceColumn, junction.sourceIdType, (c) => c.notNull())
|
|
114
|
+
.addColumn(junction.targetColumn, junction.targetIdType, (c) => c.notNull())
|
|
121
115
|
.addPrimaryKeyConstraint(`${junction.table}_pk`, [
|
|
122
116
|
junction.sourceColumn,
|
|
123
117
|
junction.targetColumn,
|
|
@@ -163,8 +157,8 @@ function buildCreateTable(
|
|
|
163
157
|
plan: TablePlan,
|
|
164
158
|
dialect: SqlDialectKind,
|
|
165
159
|
): CreateTableBuilder<string, string> {
|
|
166
|
-
let builder = db.schema.createTable(plan.table)
|
|
167
|
-
const idType = idDataType(plan.idType)
|
|
160
|
+
let builder: CreateTableBuilder<string, string> = db.schema.createTable(plan.table);
|
|
161
|
+
const idType = idDataType(plan.idType);
|
|
168
162
|
builder =
|
|
169
163
|
plan.idType === "integer"
|
|
170
164
|
? builder.addColumn("id", idType, (c) =>
|
|
@@ -177,7 +171,7 @@ function buildCreateTable(
|
|
|
177
171
|
builder = builder.addColumn("created_at", "text").addColumn("updated_at", "text");
|
|
178
172
|
}
|
|
179
173
|
for (const col of plan.columns) {
|
|
180
|
-
builder = builder.addColumn(col.column, col.dataType
|
|
174
|
+
builder = builder.addColumn(col.column, col.dataType);
|
|
181
175
|
}
|
|
182
176
|
return builder;
|
|
183
177
|
}
|
|
@@ -63,13 +63,19 @@ async function openDatabase(config: BunSqliteDialectConfig): Promise<{
|
|
|
63
63
|
// Ensure the parent directory exists (dev-server convention: <project>/.jx/data/).
|
|
64
64
|
const fsSpecifier = "node:fs";
|
|
65
65
|
const pathSpecifier = "node:path";
|
|
66
|
+
/* SAFETY: the opaque specifier is the literal "node:fs" (kept out of static analysis only so
|
|
67
|
+
bundles stay inert), and node:fs has exported mkdirSync(path, { recursive }) since Node 10.12
|
|
68
|
+
and in every Bun release. */
|
|
66
69
|
const fs = (await import(fsSpecifier)) as {
|
|
67
70
|
mkdirSync: (path: string, options: { recursive: boolean }) => void;
|
|
68
71
|
};
|
|
72
|
+
// SAFETY: the opaque specifier is the literal "node:path", whose dirname(path) returns a string.
|
|
69
73
|
const path = (await import(pathSpecifier)) as { dirname: (path: string) => string };
|
|
70
74
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
71
75
|
}
|
|
72
76
|
const sqliteSpecifier = "bun:sqlite";
|
|
77
|
+
/* SAFETY: the opaque specifier is the literal "bun:sqlite", whose Database class opens a file
|
|
78
|
+
path and structurally satisfies BunSqliteDatabaseLike (prepare/close are a subset of it). */
|
|
73
79
|
const sqlite = (await import(sqliteSpecifier)) as {
|
|
74
80
|
Database: new (file: string) => BunSqliteDatabaseLike;
|
|
75
81
|
};
|
|
@@ -88,8 +94,13 @@ function createDriver(config: BunSqliteDialectConfig): Driver {
|
|
|
88
94
|
}
|
|
89
95
|
const { sql, parameters } = compiledQuery;
|
|
90
96
|
const stmt = db.prepare(sql);
|
|
97
|
+
/* SAFETY: the parameters are the values this connector's queries bind (toStorage output,
|
|
98
|
+
ids, filter probes and timestamps: strings, numbers, booleans, bigints and null), every
|
|
99
|
+
one of which bun:sqlite accepts as a SQLQueryBindings entry. */
|
|
91
100
|
const bindings = parameters as SQLQueryBindings[];
|
|
92
101
|
if (stmt.columnNames.length > 0) {
|
|
102
|
+
/* SAFETY: Kysely instantiates R with the row shape of the compiled select, and
|
|
103
|
+
Statement.all() returns one plain object per result row keyed by column name. */
|
|
93
104
|
return { rows: stmt.all(...bindings) as R[] };
|
|
94
105
|
}
|
|
95
106
|
const info = stmt.run(...bindings);
|
package/src/dialects/d1-http.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from "kysely";
|
|
11
11
|
import type { CompiledQuery, DatabaseConnection, Dialect, Driver, QueryResult } from "kysely";
|
|
12
|
+
import type { JsonObject } from "@jxsuite/schema/types";
|
|
12
13
|
|
|
13
14
|
export interface D1HttpDialectConfig {
|
|
14
15
|
accountId: string;
|
|
@@ -25,11 +26,18 @@ interface D1HttpResponse {
|
|
|
25
26
|
errors?: { code?: number; message?: string }[];
|
|
26
27
|
result?: {
|
|
27
28
|
success?: boolean;
|
|
28
|
-
results?:
|
|
29
|
+
results?: JsonObject[];
|
|
29
30
|
meta?: { changes?: number; last_row_id?: number };
|
|
30
31
|
}[];
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** A QueryResult under construction: Kysely's own interface declares its fields readonly. */
|
|
35
|
+
interface D1HttpQueryResult<R> {
|
|
36
|
+
insertId?: bigint;
|
|
37
|
+
numAffectedRows?: bigint;
|
|
38
|
+
rows: R[];
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
/**
|
|
34
42
|
* Create the Kysely dialect over the D1 HTTP API.
|
|
35
43
|
*
|
|
@@ -62,6 +70,9 @@ async function executeHttp<R>(
|
|
|
62
70
|
},
|
|
63
71
|
method: "POST",
|
|
64
72
|
});
|
|
73
|
+
/* SAFETY: Cloudflare's v4 API answers with its { success, errors, result } envelope (or the catch
|
|
74
|
+
turns an unparsable body into null); every field below is read optionally, and a body without
|
|
75
|
+
`success: true` is rejected before `result` is touched. */
|
|
65
76
|
const body = (await response.json().catch(() => null)) as D1HttpResponse | null;
|
|
66
77
|
if (!response.ok || !body?.success) {
|
|
67
78
|
const detail =
|
|
@@ -73,11 +84,16 @@ async function executeHttp<R>(
|
|
|
73
84
|
}
|
|
74
85
|
const [result] = body.result ?? [];
|
|
75
86
|
const meta = result?.meta ?? {};
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
/* SAFETY: Kysely instantiates R with the row shape of the compiled statement, and the D1 query
|
|
88
|
+
endpoint returns each result row as a JSON object keyed by column name. */
|
|
89
|
+
const queryResult: D1HttpQueryResult<R> = { rows: (result?.results ?? []) as R[] };
|
|
90
|
+
if (meta.last_row_id !== undefined) {
|
|
91
|
+
queryResult.insertId = BigInt(meta.last_row_id);
|
|
92
|
+
}
|
|
93
|
+
if (meta.changes !== undefined) {
|
|
94
|
+
queryResult.numAffectedRows = BigInt(meta.changes);
|
|
95
|
+
}
|
|
96
|
+
return queryResult;
|
|
81
97
|
}
|
|
82
98
|
|
|
83
99
|
/** Build the stateless HTTP driver. */
|
package/src/provider-utils.ts
CHANGED
|
@@ -9,25 +9,43 @@
|
|
|
9
9
|
|
|
10
10
|
import { Kysely, sql } from "kysely";
|
|
11
11
|
import { syncTables } from "./ddl.ts";
|
|
12
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
12
13
|
import type { Dialect } from "kysely";
|
|
14
|
+
import type { JsonObject } from "@jxsuite/schema/types";
|
|
13
15
|
import type { SqlDialectKind } from "./columns.ts";
|
|
14
16
|
import type { DynamicDatabase } from "./query.ts";
|
|
15
17
|
import type { ConnectionDef, DeploySchemaResult, TableDef, TestConnectionResult } from "./types.ts";
|
|
16
18
|
|
|
19
|
+
/** A Hyperdrive binding as the Supabase provider reads it: only its pooled connection string. */
|
|
20
|
+
export interface HyperdriveBinding {
|
|
21
|
+
readonly connectionString: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One host environment entry: an env var or secret (a string), or a platform binding a connection
|
|
26
|
+
* names through its `binding` field (a D1 database, a Hyperdrive config).
|
|
27
|
+
*/
|
|
28
|
+
export type HostEnvValue = string | D1Database | HyperdriveBinding;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The host environment a connection resolves against: process.env merged under `.dev.vars` plus
|
|
32
|
+
* `JX_PROJECT_ROOT` on node/Bun hosts, or the Worker `env` (vars, secrets and bindings) on
|
|
33
|
+
* Cloudflare. Connections carry identifiers only; every secret and binding is read from here.
|
|
34
|
+
*/
|
|
35
|
+
export type HostEnv = Readonly<Record<string, HostEnvValue | undefined>>;
|
|
36
|
+
|
|
17
37
|
/** The static capability surface of a connector provider implementation. */
|
|
18
38
|
export interface ConnectorProvider {
|
|
19
39
|
kind: SqlDialectKind;
|
|
20
|
-
dialect: (connection: ConnectionDef, env:
|
|
40
|
+
dialect: (connection: ConnectionDef, env: HostEnv) => Dialect | Promise<Dialect>;
|
|
21
41
|
deploySchema: (
|
|
22
42
|
tables: Record<string, TableDef>,
|
|
23
43
|
connection: ConnectionDef,
|
|
24
|
-
options: { env:
|
|
44
|
+
options: { env: HostEnv; dryRun?: boolean },
|
|
25
45
|
) => Promise<DeploySchemaResult>;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
env: Record<string, unknown>,
|
|
30
|
-
) => Promise<TestConnectionResult>;
|
|
46
|
+
/** The wrangler binding fragment this connection provisions (`jx db push` patches wrangler.jsonc). */
|
|
47
|
+
bindings: (connection: ConnectionDef) => JsonObject;
|
|
48
|
+
testConnection: (connection: ConnectionDef, env: HostEnv) => Promise<TestConnectionResult>;
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
/**
|
|
@@ -76,11 +94,7 @@ export function tablesForConnection(
|
|
|
76
94
|
tables: Record<string, TableDef>,
|
|
77
95
|
connectionName: string,
|
|
78
96
|
): Record<string, TableDef> {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
out[name] = def;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return out;
|
|
97
|
+
return Object.fromEntries(
|
|
98
|
+
Object.entries(tables).filter(([, def]) => def.connection === connectionName),
|
|
99
|
+
);
|
|
86
100
|
}
|
package/src/query.ts
CHANGED
|
@@ -9,42 +9,61 @@
|
|
|
9
9
|
* empty.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { isBoolean, isJsonObject, isNumber, isString } from "@jxsuite/schema/guards";
|
|
12
13
|
import type { ExpressionBuilder, ExpressionWrapper, SelectQueryBuilder, SqlBool } from "kysely";
|
|
14
|
+
import type { JsonObject, JsonValue } from "@jxsuite/schema/types";
|
|
13
15
|
import type { ColumnSpec } from "./columns.ts";
|
|
14
16
|
|
|
15
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* One filter rule of the shared content grammar. Rules arrive as wire JSON, and normalization only
|
|
19
|
+
* requires a string `field`: an absent or unrecognized `op` matches everything (see
|
|
20
|
+
* ruleExpression).
|
|
21
|
+
*/
|
|
16
22
|
export interface FilterRule {
|
|
17
23
|
field: string;
|
|
18
|
-
op
|
|
19
|
-
value?:
|
|
24
|
+
op?: JsonValue;
|
|
25
|
+
value?: JsonValue;
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
/** One sort rule; any `order` other than "desc" sorts ascending. */
|
|
22
29
|
export interface SortRule {
|
|
23
30
|
field: string;
|
|
24
|
-
order?:
|
|
31
|
+
order?: JsonValue;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
34
|
+
/**
|
|
35
|
+
* A value a SQL driver reads or binds: JSON scalars and structures (postgres json columns, arrays),
|
|
36
|
+
* 64-bit integers, blobs and timestamps.
|
|
37
|
+
*/
|
|
38
|
+
export type SqlValue = JsonValue | bigint | Uint8Array | Date;
|
|
39
|
+
|
|
40
|
+
/** One dynamic-table row keyed by physical column name (columns are only known at runtime). */
|
|
41
|
+
export type DynamicRow = Record<string, SqlValue>;
|
|
42
|
+
|
|
43
|
+
/** Database shape for dynamic tables: every table is a bag of runtime-known columns. */
|
|
44
|
+
export type DynamicDatabase = Record<string, DynamicRow>;
|
|
29
45
|
|
|
30
|
-
type DynamicSelect = SelectQueryBuilder<DynamicDatabase, string,
|
|
46
|
+
type DynamicSelect = SelectQueryBuilder<DynamicDatabase, string, DynamicRow>;
|
|
31
47
|
type DynamicEb = ExpressionBuilder<DynamicDatabase, string>;
|
|
32
48
|
type BoolExpr = ExpressionWrapper<DynamicDatabase, string, SqlBool>;
|
|
33
49
|
|
|
50
|
+
/** A wire rule object naming a string `field` — the one requirement filter and sort rules share. */
|
|
51
|
+
function isFieldRule(rule: JsonValue): rule is JsonObject & { field: string } {
|
|
52
|
+
return isJsonObject(rule) && isString(rule.field);
|
|
53
|
+
}
|
|
54
|
+
|
|
34
55
|
/**
|
|
35
56
|
* Normalize a wire filter (rule array or equality-object shorthand) into rules.
|
|
36
57
|
*
|
|
37
|
-
* @param {
|
|
58
|
+
* @param {JsonValue | undefined} filter
|
|
38
59
|
* @returns {FilterRule[]}
|
|
39
60
|
*/
|
|
40
|
-
export function normalizeFilter(filter:
|
|
61
|
+
export function normalizeFilter(filter: JsonValue | undefined): FilterRule[] {
|
|
41
62
|
if (Array.isArray(filter)) {
|
|
42
|
-
return (
|
|
43
|
-
(rule) => rule && typeof rule === "object" && typeof rule.field === "string",
|
|
44
|
-
);
|
|
63
|
+
return filter.filter((rule) => isFieldRule(rule));
|
|
45
64
|
}
|
|
46
|
-
if (filter
|
|
47
|
-
return Object.entries(filter
|
|
65
|
+
if (isJsonObject(filter)) {
|
|
66
|
+
return Object.entries(filter).map(([field, value]) => ({
|
|
48
67
|
field,
|
|
49
68
|
op: "==",
|
|
50
69
|
value,
|
|
@@ -54,11 +73,9 @@ export function normalizeFilter(filter: unknown): FilterRule[] {
|
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
/** Normalize a wire sort (single rule or list) into rules. */
|
|
57
|
-
export function normalizeSort(sort:
|
|
76
|
+
export function normalizeSort(sort: JsonValue | undefined): SortRule[] {
|
|
58
77
|
const list = Array.isArray(sort) ? sort : sort ? [sort] : [];
|
|
59
|
-
return (
|
|
60
|
-
(rule) => rule && typeof rule === "object" && typeof rule.field === "string",
|
|
61
|
-
);
|
|
78
|
+
return list.filter((rule) => isFieldRule(rule));
|
|
62
79
|
}
|
|
63
80
|
|
|
64
81
|
/** Resolve a grammar field name to its physical column (`author` → `author_id` for refs). */
|
|
@@ -140,13 +157,13 @@ function ruleExpression(
|
|
|
140
157
|
}
|
|
141
158
|
default: {
|
|
142
159
|
// Unknown ops match everything, like the in-memory evaluator.
|
|
143
|
-
return eb.lit(true
|
|
160
|
+
return eb.lit<SqlBool>(true);
|
|
144
161
|
}
|
|
145
162
|
}
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
/** LIKE pattern for `contains`: JSON columns match the JSON-encoded element. */
|
|
149
|
-
function containsPattern(spec: ColumnSpec | null, value:
|
|
166
|
+
function containsPattern(spec: ColumnSpec | null, value: JsonValue | undefined): string {
|
|
150
167
|
if (spec?.storage === "json") {
|
|
151
168
|
const encoded = JSON.stringify(value);
|
|
152
169
|
return `%${escapeLike(encoded)}%`;
|
|
@@ -160,7 +177,7 @@ function escapeLike(text: string): string {
|
|
|
160
177
|
}
|
|
161
178
|
|
|
162
179
|
/** Coerce a filter probe value to the column's storage representation. */
|
|
163
|
-
function storageValue(spec: ColumnSpec | null, value:
|
|
180
|
+
function storageValue(spec: ColumnSpec | null, value: JsonValue | undefined): string | number {
|
|
164
181
|
if (spec?.storage === "boolean") {
|
|
165
182
|
const truthy = value === true || value === "true" || value === 1 || value === "1";
|
|
166
183
|
return truthy ? 1 : 0;
|
|
@@ -168,10 +185,10 @@ function storageValue(spec: ColumnSpec | null, value: unknown): string | number
|
|
|
168
185
|
if (spec?.storage === "json") {
|
|
169
186
|
return JSON.stringify(value);
|
|
170
187
|
}
|
|
171
|
-
if (
|
|
188
|
+
if (isNumber(value)) {
|
|
172
189
|
return value;
|
|
173
190
|
}
|
|
174
|
-
if (
|
|
191
|
+
if (isBoolean(value)) {
|
|
175
192
|
return value ? 1 : 0;
|
|
176
193
|
}
|
|
177
194
|
return String(value);
|