@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/connector",
3
- "version": "0.6.2",
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": "^3.0.0",
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.20261002.1",
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: unknown): RefTarget | null {
32
- if (typeof pointer !== "string") {
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: string;
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: string;
73
- targetIdType: string;
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"): string {
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): string {
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 {unknown} value - Already-validated payload value
247
+ * @param {JsonValue | undefined} value - Already-validated payload value
244
248
  * @param {SqlDialectKind} dialect
245
- * @returns {unknown}
249
+ * @returns {JsonValue}
246
250
  */
247
- export function toStorage(spec: ColumnSpec, value: unknown, dialect: SqlDialectKind): unknown {
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 {Record<string, unknown>} row - Raw row from the driver
269
- * @returns {Record<string, unknown>}
276
+ * @param {DynamicRow} row - Raw row from the driver
277
+ * @returns {DynamicRow}
270
278
  */
271
- export function fromStorage(
272
- specs: ColumnSpec[],
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" && typeof value !== "boolean") {
282
- out[spec.column] = value === 1 || value === "1" || value === true;
283
- } else if (spec.storage === "json" && typeof value === "string") {
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
- out[spec.column] = JSON.parse(value) as unknown;
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 { ConnectorProvider } from "./provider-utils.ts";
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: Record<string, ConnectorProvider> = {
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 {Record<string, unknown>} env - Host environment (bindings + env vars)
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: Record<string, unknown>,
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 = PROVIDERS[connection.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 { ConnectorProvider } from "./provider-utils.ts";
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: Record<string, unknown>): Dialect {
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 (typeof apiToken === "string" && typeof accountId === "string" && databaseId) {
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
- d1_databases: [
40
- {
41
- binding: connection.binding ?? "DB",
42
- ...(connection.databaseId === undefined ? {} : { database_id: connection.databaseId }),
43
- database_name: connection.$name ?? connection.binding ?? "DB",
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: Record<string, unknown>; dryRun?: boolean },
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: Record<string, unknown>) => {
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 { ColumnDataType, CreateTableBuilder, Kysely } from "kysely";
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<unknown>;
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 as ColumnDataType, (c) =>
116
- c.notNull(),
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) as CreateTableBuilder<string, string>;
167
- const idType = idDataType(plan.idType) as ColumnDataType;
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 as ColumnDataType);
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);
@@ -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?: Record<string, unknown>[];
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
- 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
- };
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. */
@@ -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: Record<string, unknown>) => Dialect | Promise<Dialect>;
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: Record<string, unknown>; dryRun?: boolean },
44
+ options: { env: HostEnv; dryRun?: boolean },
25
45
  ) => Promise<DeploySchemaResult>;
26
- bindings: (connection: ConnectionDef) => Record<string, unknown>;
27
- testConnection: (
28
- connection: ConnectionDef,
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
- 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;
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
- /** One filter rule of the shared content grammar. */
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: string;
19
- value?: unknown;
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?: string;
31
+ order?: JsonValue;
25
32
  }
26
33
 
27
- /** Loosely-typed database shape for dynamic tables (columns are only known at runtime). */
28
- export type DynamicDatabase = Record<string, Record<string, unknown>>;
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, Record<string, unknown>>;
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 {unknown} filter
58
+ * @param {JsonValue | undefined} filter
38
59
  * @returns {FilterRule[]}
39
60
  */
40
- export function normalizeFilter(filter: unknown): FilterRule[] {
61
+ export function normalizeFilter(filter: JsonValue | undefined): FilterRule[] {
41
62
  if (Array.isArray(filter)) {
42
- return (filter as FilterRule[]).filter(
43
- (rule) => rule && typeof rule === "object" && typeof rule.field === "string",
44
- );
63
+ return filter.filter((rule) => isFieldRule(rule));
45
64
  }
46
- if (filter && typeof filter === "object") {
47
- return Object.entries(filter as Record<string, unknown>).map(([field, value]) => ({
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: unknown): SortRule[] {
76
+ export function normalizeSort(sort: JsonValue | undefined): SortRule[] {
58
77
  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
- );
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 as SqlBool);
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: unknown): string {
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: unknown): string | number {
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 (typeof value === "number") {
188
+ if (isNumber(value)) {
172
189
  return value;
173
190
  }
174
- if (typeof value === "boolean") {
191
+ if (isBoolean(value)) {
175
192
  return value ? 1 : 0;
176
193
  }
177
194
  return String(value);