@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.
package/src/columns.ts ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Columns — single source of truth for field schema → SQL column mapping and value coercion.
3
+ *
4
+ * Table column schemas are plain Jx field schemas (the core field union) plus relationship refs
5
+ * (specs/relationships.md). This module derives the physical column plan for a table — scalar
6
+ * columns, reference FK columns, and junction tables for table↔table to-many refs — and the two-way
7
+ * value coercion between JSON payloads and SQL storage.
8
+ *
9
+ * Storage model (v1, uniform across sqlite and postgres):
10
+ *
11
+ * - String → text · number → real/double precision · integer → integer
12
+ * - Boolean → integer 0/1 on sqlite, boolean on postgres
13
+ * - Array/object → JSON-serialized text
14
+ * - To-one reference (`{ $ref }`) → text FK column named `<field>_id` storing the target id
15
+ * - To-many reference to a table (`array` of `$ref` into the same data section) → junction table
16
+ * `${sourceTable}_${fieldName}` (relationships.md §3), no column on the source table
17
+ * - To-many reference to any other section → JSON text column of id strings
18
+ */
19
+
20
+ import type { TableDef } from "./types.ts";
21
+
22
+ export type SqlDialectKind = "sqlite" | "postgres";
23
+
24
+ /** Parsed relationship-ref target: `#/<sectionKey>/<entryName>`. */
25
+ export interface RefTarget {
26
+ section: string;
27
+ name: string;
28
+ }
29
+
30
+ /** 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") {
33
+ return null;
34
+ }
35
+ const match = pointer.match(/^#\/([A-Za-z][\w-]*)\/([\w.-]+)$/);
36
+ if (!match) {
37
+ return null;
38
+ }
39
+ return { name: match[2]!, section: match[1]! };
40
+ }
41
+
42
+ /** How a column's values are stored and coerced. */
43
+ export type ColumnStorage = "text" | "number" | "integer" | "boolean" | "json";
44
+
45
+ /** One physical column derived from a schema field. */
46
+ export interface ColumnSpec {
47
+ /** Schema field name (the wire-payload key). */
48
+ field: string;
49
+ /** Physical column name (`<field>_id` for to-one references). */
50
+ column: string;
51
+ /** SQL data type passed to the Kysely schema builder. */
52
+ dataType: string;
53
+ storage: ColumnStorage;
54
+ /** Set for reference fields (to-one FK columns and to-many JSON id lists). */
55
+ ref: RefTarget | null;
56
+ /** True for to-many reference fields stored as JSON id arrays (non-table targets). */
57
+ manyRef: boolean;
58
+ }
59
+
60
+ /** A junction table materializing a table↔table to-many reference (relationships.md §3). */
61
+ export interface JunctionSpec {
62
+ /** Junction table name: `${sourceTable}_${fieldName}`. */
63
+ table: string;
64
+ /** Schema field on the source table this junction materializes. */
65
+ field: string;
66
+ sourceTable: string;
67
+ targetTable: string;
68
+ /** `${sourceTable}_id`. */
69
+ sourceColumn: string;
70
+ /** `${targetTable}_id`, suffixed `_2` when the reference is self-referential. */
71
+ targetColumn: string;
72
+ sourceIdType: string;
73
+ targetIdType: string;
74
+ }
75
+
76
+ /** The full physical plan for one table. */
77
+ export interface TablePlan {
78
+ table: string;
79
+ idType: "uuid" | "integer";
80
+ timestamps: boolean;
81
+ columns: ColumnSpec[];
82
+ junctions: JunctionSpec[];
83
+ indexes: string[][];
84
+ }
85
+
86
+ /** SQL data type of a table's primary key. */
87
+ export function idDataType(idKind: "uuid" | "integer"): string {
88
+ return idKind === "uuid" ? "text" : "integer";
89
+ }
90
+
91
+ /** Map a scalar field schema type to the SQL data type for a dialect. */
92
+ function scalarDataType(type: string | undefined, dialect: SqlDialectKind): string {
93
+ switch (type) {
94
+ case "number": {
95
+ return dialect === "postgres" ? "double precision" : "real";
96
+ }
97
+ case "integer": {
98
+ return "integer";
99
+ }
100
+ case "boolean": {
101
+ return dialect === "postgres" ? "boolean" : "integer";
102
+ }
103
+ default: {
104
+ return "text";
105
+ }
106
+ }
107
+ }
108
+
109
+ /** Storage class for a scalar field schema type. */
110
+ function scalarStorage(type: string | undefined): ColumnStorage {
111
+ switch (type) {
112
+ case "number": {
113
+ return "number";
114
+ }
115
+ case "integer": {
116
+ return "integer";
117
+ }
118
+ case "boolean": {
119
+ return "boolean";
120
+ }
121
+ case "array":
122
+ case "object": {
123
+ return "json";
124
+ }
125
+ default: {
126
+ return "text";
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Derive the physical plan for a table: columns, junction tables, and index column lists.
133
+ *
134
+ * @param {string} tableName - The data-section key naming the table
135
+ * @param {TableDef} table - The table definition
136
+ * @param {Record<string, TableDef>} allTables - The full data section (junction id types)
137
+ * @param {SqlDialectKind} dialect - SQL dialect family
138
+ * @returns {TablePlan}
139
+ */
140
+ export function planTable(
141
+ tableName: string,
142
+ table: TableDef,
143
+ allTables: Record<string, TableDef>,
144
+ dialect: SqlDialectKind,
145
+ ): TablePlan {
146
+ const idType = table.id ?? "uuid";
147
+ const columns: ColumnSpec[] = [];
148
+ const junctions: JunctionSpec[] = [];
149
+
150
+ for (const [field, schema] of Object.entries(table.schema.properties ?? {})) {
151
+ const toOne = parseRefPointer(schema.$ref);
152
+ if (toOne) {
153
+ columns.push({
154
+ column: `${field}_id`,
155
+ dataType: "text",
156
+ field,
157
+ manyRef: false,
158
+ ref: toOne,
159
+ storage: "text",
160
+ });
161
+ continue;
162
+ }
163
+
164
+ const itemsRef = schema.type === "array" ? parseRefPointer(schema.items?.$ref) : null;
165
+ if (itemsRef && itemsRef.section === "data" && allTables[itemsRef.name]) {
166
+ const target = allTables[itemsRef.name]!;
167
+ const selfRef = itemsRef.name === tableName;
168
+ junctions.push({
169
+ field,
170
+ sourceColumn: `${tableName}_id`,
171
+ sourceIdType: idDataType(idType),
172
+ sourceTable: tableName,
173
+ table: `${tableName}_${field}`,
174
+ targetColumn: selfRef ? `${itemsRef.name}_id_2` : `${itemsRef.name}_id`,
175
+ targetIdType: idDataType(target.id ?? "uuid"),
176
+ targetTable: itemsRef.name,
177
+ });
178
+ continue;
179
+ }
180
+ if (itemsRef) {
181
+ // To-many reference into a non-table section (e.g. content): JSON array of id strings.
182
+ columns.push({
183
+ column: field,
184
+ dataType: "text",
185
+ field,
186
+ manyRef: true,
187
+ ref: itemsRef,
188
+ storage: "json",
189
+ });
190
+ continue;
191
+ }
192
+
193
+ columns.push({
194
+ column: field,
195
+ dataType: scalarDataType(schema.type, dialect),
196
+ field,
197
+ manyRef: false,
198
+ ref: null,
199
+ storage: scalarStorage(schema.type),
200
+ });
201
+ }
202
+
203
+ // The owner column backs "owner" permission rules even when the schema does not declare it —
204
+ // The data mount writes it via setColumns and scopes reads/writes through whereOwner.
205
+ if (table.ownerField && !columns.some((c) => c.field === table.ownerField)) {
206
+ columns.push({
207
+ column: table.ownerField,
208
+ dataType: "text",
209
+ field: table.ownerField,
210
+ manyRef: false,
211
+ ref: null,
212
+ storage: "text",
213
+ });
214
+ }
215
+
216
+ const byField = new Map(columns.map((c) => [c.field, c.column]));
217
+ const indexes: string[][] = [];
218
+ for (const index of table.indexes ?? []) {
219
+ const fields = Array.isArray(index) ? index : [index];
220
+ const cols = fields.map((f) => byField.get(f) ?? f);
221
+ indexes.push(cols);
222
+ }
223
+
224
+ return {
225
+ columns,
226
+ idType,
227
+ indexes,
228
+ junctions,
229
+ table: tableName,
230
+ timestamps: table.timestamps !== false,
231
+ };
232
+ }
233
+
234
+ /** Generate a fresh primary key for an insert, or undefined when the database assigns one. */
235
+ export function newRowId(idKind: "uuid" | "integer"): string | undefined {
236
+ return idKind === "uuid" ? crypto.randomUUID() : undefined;
237
+ }
238
+
239
+ /**
240
+ * Coerce one JSON payload value to its SQL storage representation.
241
+ *
242
+ * @param {ColumnSpec} spec
243
+ * @param {unknown} value - Already-validated payload value
244
+ * @param {SqlDialectKind} dialect
245
+ * @returns {unknown}
246
+ */
247
+ export function toStorage(spec: ColumnSpec, value: unknown, dialect: SqlDialectKind): unknown {
248
+ if (value === null || value === undefined) {
249
+ return null;
250
+ }
251
+ switch (spec.storage) {
252
+ case "boolean": {
253
+ return dialect === "postgres" ? value === true : value === true ? 1 : 0;
254
+ }
255
+ case "json": {
256
+ return JSON.stringify(value);
257
+ }
258
+ default: {
259
+ return value;
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Coerce a database row back to its JSON payload shape (booleans, JSON columns).
266
+ *
267
+ * @param {ColumnSpec[]} specs - Column plan of the table
268
+ * @param {Record<string, unknown>} row - Raw row from the driver
269
+ * @returns {Record<string, unknown>}
270
+ */
271
+ export function fromStorage(
272
+ specs: ColumnSpec[],
273
+ row: Record<string, unknown>,
274
+ ): Record<string, unknown> {
275
+ const out: Record<string, unknown> = { ...row };
276
+ for (const spec of specs) {
277
+ const value = out[spec.column];
278
+ if (value === null || value === undefined) {
279
+ continue;
280
+ }
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") {
284
+ try {
285
+ out[spec.column] = JSON.parse(value) as unknown;
286
+ } catch {
287
+ // Malformed stored JSON stays a string rather than failing the read.
288
+ }
289
+ }
290
+ }
291
+ return out;
292
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Connectors — the first-party provider map and the `resolveDialect` seam.
3
+ *
4
+ * `resolveDialect(connectionName, projectConfig, env)` is the contract dependents build on (the
5
+ * auth extension resolves its Better Auth database through it): look the connection up in the
6
+ * project's `connections` section, find its provider, and return the Kysely dialect plus the SQL
7
+ * dialect family. Hosts with an extension registry should prefer registry dispatch — this map
8
+ * covers the first-party providers only.
9
+ */
10
+
11
+ import { D1 } from "./d1.ts";
12
+ import { Sqlite } from "./sqlite.ts";
13
+ import { Supabase } from "./supabase.ts";
14
+ import type { Dialect } from "kysely";
15
+ import type { SqlDialectKind } from "./columns.ts";
16
+ import type { ConnectorProvider } from "./provider-utils.ts";
17
+ import type { ConnectionDef } from "./types.ts";
18
+
19
+ export type { ConnectorProvider } from "./provider-utils.ts";
20
+ export { tablesForConnection } from "./provider-utils.ts";
21
+ export { D1 } from "./d1.ts";
22
+ export { Sqlite } from "./sqlite.ts";
23
+ export { Supabase } from "./supabase.ts";
24
+
25
+ /** First-party providers keyed by their `connector.provider` id. */
26
+ export const PROVIDERS: Record<string, ConnectorProvider> = {
27
+ d1: D1,
28
+ sqlite: Sqlite,
29
+ supabase: Supabase,
30
+ };
31
+
32
+ /** The `connections`-bearing slice of a project config. */
33
+ export interface ConnectionsConfig {
34
+ connections?: Record<string, ConnectionDef>;
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ /**
39
+ * Resolve a named connection to its Kysely dialect and SQL dialect family.
40
+ *
41
+ * @param {string} connectionName - Key of the project.json `connections` section
42
+ * @param {ConnectionsConfig} projectConfig - The (partial) project config
43
+ * @param {Record<string, unknown>} env - Host environment (bindings + env vars)
44
+ * @returns {Promise<{ dialect: Dialect; type: SqlDialectKind }>}
45
+ */
46
+ export async function resolveDialect(
47
+ connectionName: string,
48
+ projectConfig: ConnectionsConfig,
49
+ env: Record<string, unknown>,
50
+ ): Promise<{ dialect: Dialect; type: SqlDialectKind }> {
51
+ const connections = projectConfig.connections ?? {};
52
+ const connection = connections[connectionName];
53
+ if (!connection) {
54
+ throw new Error(`Unknown connection "${connectionName}" (no such connections entry)`);
55
+ }
56
+ const provider = PROVIDERS[connection.provider];
57
+ if (!provider) {
58
+ throw new Error(
59
+ `Connection "${connectionName}" names unknown provider "${connection.provider}" ` +
60
+ `(first-party providers: ${Object.keys(PROVIDERS).join(", ")})`,
61
+ );
62
+ }
63
+ const dialect = await provider.dialect({ ...connection, $name: connectionName }, env);
64
+ return { dialect, type: provider.kind };
65
+ }
package/src/d1.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * D1 provider — Cloudflare D1 connections.
3
+ *
4
+ * Inside a deployed Worker the connection's `binding` names a real D1 binding on `env` and queries
5
+ * go through kysely-d1. Everywhere else (deploy, tests, compiler-timing SSG bakes) the provider
6
+ * falls back to the D1 HTTP API using `CLOUDFLARE_API_TOKEN` (+ `accountId` from the connection or
7
+ * `CLOUDFLARE_ACCOUNT_ID`). Connection entries carry identifiers only; the token is always an env
8
+ * value (specs/extensions.md §13).
9
+ */
10
+
11
+ import { D1Dialect } from "kysely-d1";
12
+ import { createD1HttpDialect } from "./dialects/d1-http.ts";
13
+ import { deployWithDialect, testWithDialect } from "./provider-utils.ts";
14
+ import type { D1Database } from "@cloudflare/workers-types";
15
+ import type { Dialect } from "kysely";
16
+ import type { ConnectorProvider } from "./provider-utils.ts";
17
+ import type { ConnectionDef } from "./types.ts";
18
+
19
+ /** Resolve a D1 dialect for a connection (binding first, HTTP API fallback). */
20
+ function d1Dialect(connection: ConnectionDef, env: Record<string, unknown>): Dialect {
21
+ const { binding, databaseId, $name = "connection" } = connection;
22
+ const bound = binding ? env[binding] : undefined;
23
+ if (bound) {
24
+ return new D1Dialect({ database: bound as D1Database });
25
+ }
26
+ const apiToken = env["CLOUDFLARE_API_TOKEN"];
27
+ const accountId = connection.accountId ?? env["CLOUDFLARE_ACCOUNT_ID"];
28
+ if (typeof apiToken === "string" && typeof accountId === "string" && databaseId) {
29
+ return createD1HttpDialect({ accountId, apiToken, databaseId });
30
+ }
31
+ throw new Error(
32
+ `D1 connection "${$name}" is unreachable: no "${binding ?? "<binding>"}" binding on env, ` +
33
+ `and the HTTP API needs databaseId + CLOUDFLARE_API_TOKEN + accountId/CLOUDFLARE_ACCOUNT_ID`,
34
+ );
35
+ }
36
+
37
+ 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
+ }),
47
+ deploySchema: async (
48
+ tables: Parameters<ConnectorProvider["deploySchema"]>[0],
49
+ connection: ConnectionDef,
50
+ options: { env: Record<string, unknown>; dryRun?: boolean },
51
+ ) => {
52
+ const dialect = d1Dialect(connection, options.env);
53
+ return deployWithDialect(dialect, "sqlite", tables, { dryRun: options.dryRun ?? false });
54
+ },
55
+ dialect: d1Dialect,
56
+ kind: "sqlite",
57
+ testConnection: async (connection: ConnectionDef, env: Record<string, unknown>) => {
58
+ try {
59
+ return await testWithDialect(d1Dialect(connection, env));
60
+ } catch (error) {
61
+ return { error: error instanceof Error ? error.message : String(error), ok: false };
62
+ }
63
+ },
64
+ } satisfies ConnectorProvider;
package/src/ddl.ts ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * DDL — additive-only schema sync via the Kysely schema builder and introspector.
3
+ *
4
+ * The sync creates missing tables (with id and timestamp columns), adds missing columns to existing
5
+ * tables, materializes junction tables for table↔table to-many references (specs/relationships.md
6
+ * §3), and creates missing indexes. It NEVER drops or retypes anything: type drift, columns unknown
7
+ * to the schema, and orphaned junction tables are reported as warnings, not fixed. `dryRun`
8
+ * compiles the exact statements without executing them — the shape behind `jx db push --dry-run`
9
+ * and the deploySchema capability.
10
+ */
11
+
12
+ import { sql } from "kysely";
13
+ import { idDataType, planTable } from "./columns.ts";
14
+ import type { ColumnDataType, CreateTableBuilder, Kysely } from "kysely";
15
+ import type { SqlDialectKind, TablePlan } from "./columns.ts";
16
+ import type { DynamicDatabase } from "./query.ts";
17
+ import type { DeploySchemaResult, TableDef } from "./types.ts";
18
+
19
+ export interface SyncOptions {
20
+ dialect: SqlDialectKind;
21
+ /** Compile statements without executing them. */
22
+ dryRun?: boolean;
23
+ }
24
+
25
+ /** Columns every synced table owns besides the schema fields. */
26
+ const SYSTEM_COLUMNS = new Set(["id", "created_at", "updated_at"]);
27
+
28
+ interface Executable {
29
+ compile: () => { sql: string };
30
+ execute: () => Promise<unknown>;
31
+ }
32
+
33
+ /**
34
+ * Synchronize the `data` section's tables into a database, additively.
35
+ *
36
+ * @param {Kysely<DynamicDatabase>} db - Kysely instance over the target connection
37
+ * @param {Record<string, TableDef>} tables - Table definitions (the tables of one connection)
38
+ * @param {SyncOptions} options
39
+ * @returns {Promise<DeploySchemaResult>}
40
+ */
41
+ export async function syncTables(
42
+ db: Kysely<DynamicDatabase>,
43
+ tables: Record<string, TableDef>,
44
+ options: SyncOptions,
45
+ ): Promise<DeploySchemaResult> {
46
+ const { dialect, dryRun = false } = options;
47
+ const statements: string[] = [];
48
+ const warnings: string[] = [];
49
+
50
+ const plans = Object.entries(tables).map(([name, def]) => planTable(name, def, tables, dialect));
51
+
52
+ const introspected = await db.introspection.getTables({ withInternalKyselyTables: false });
53
+ const existing = new Map(introspected.map((t) => [t.name, t]));
54
+ const existingIndexes = await indexNames(db, dialect);
55
+
56
+ const run = async (builder: Executable): Promise<void> => {
57
+ statements.push(builder.compile().sql);
58
+ if (!dryRun) {
59
+ await builder.execute();
60
+ }
61
+ };
62
+
63
+ const desiredNames = new Set<string>();
64
+ for (const plan of plans) {
65
+ desiredNames.add(plan.table);
66
+ for (const junction of plan.junctions) {
67
+ desiredNames.add(junction.table);
68
+ }
69
+ }
70
+
71
+ for (const plan of plans) {
72
+ const table = existing.get(plan.table);
73
+ if (!table) {
74
+ await run(buildCreateTable(db, plan, dialect));
75
+ } else {
76
+ const byName = new Map(table.columns.map((c) => [c.name, c]));
77
+ for (const col of plan.columns) {
78
+ const current = byName.get(col.column);
79
+ if (!current) {
80
+ await run(
81
+ db.schema.alterTable(plan.table).addColumn(col.column, col.dataType as ColumnDataType),
82
+ );
83
+ continue;
84
+ }
85
+ if (current.dataType.toLowerCase() !== col.dataType.toLowerCase()) {
86
+ warnings.push(
87
+ `${plan.table}.${col.column}: type drift (database has "${current.dataType}", ` +
88
+ `schema wants "${col.dataType}") — additive-only sync never retypes`,
89
+ );
90
+ }
91
+ }
92
+ if (plan.timestamps) {
93
+ for (const stamp of ["created_at", "updated_at"]) {
94
+ if (!byName.has(stamp)) {
95
+ await run(db.schema.alterTable(plan.table).addColumn(stamp, "text"));
96
+ }
97
+ }
98
+ }
99
+ const declared = new Set(plan.columns.map((c) => c.column));
100
+ for (const col of table.columns) {
101
+ if (!declared.has(col.name) && !SYSTEM_COLUMNS.has(col.name)) {
102
+ warnings.push(
103
+ `${plan.table}.${col.name}: column exists in the database but not in the schema — ` +
104
+ `never dropped`,
105
+ );
106
+ }
107
+ }
108
+ }
109
+
110
+ for (const junction of plan.junctions) {
111
+ if (!existing.has(junction.table)) {
112
+ await run(
113
+ db.schema
114
+ .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
+ )
121
+ .addPrimaryKeyConstraint(`${junction.table}_pk`, [
122
+ junction.sourceColumn,
123
+ junction.targetColumn,
124
+ ]),
125
+ );
126
+ }
127
+ const indexName = `${junction.table}_${junction.targetColumn}_idx`;
128
+ if (!existingIndexes.has(indexName)) {
129
+ await run(
130
+ db.schema.createIndex(indexName).on(junction.table).columns([junction.targetColumn]),
131
+ );
132
+ }
133
+ }
134
+
135
+ for (const columns of plan.indexes) {
136
+ const indexName = `${plan.table}_${columns.join("_")}_idx`;
137
+ if (!existingIndexes.has(indexName)) {
138
+ await run(db.schema.createIndex(indexName).on(plan.table).columns(columns));
139
+ }
140
+ }
141
+ }
142
+
143
+ // Orphaned junction tables (a renamed to-many field leaves its old junction behind).
144
+ const knownTables = new Set(plans.map((p) => p.table));
145
+ for (const name of existing.keys()) {
146
+ if (desiredNames.has(name) || knownTables.has(name)) {
147
+ continue;
148
+ }
149
+ const looksLikeJunction = [...knownTables].some((t) => name.startsWith(`${t}_`));
150
+ if (looksLikeJunction) {
151
+ warnings.push(
152
+ `${name}: junction table no longer matches any to-many field — orphaned, never dropped`,
153
+ );
154
+ }
155
+ }
156
+
157
+ return { applied: !dryRun, statements, warnings };
158
+ }
159
+
160
+ /** Build the CREATE TABLE for a plan (id strategy, timestamps, schema columns). */
161
+ function buildCreateTable(
162
+ db: Kysely<DynamicDatabase>,
163
+ plan: TablePlan,
164
+ dialect: SqlDialectKind,
165
+ ): CreateTableBuilder<string, string> {
166
+ let builder = db.schema.createTable(plan.table) as CreateTableBuilder<string, string>;
167
+ const idType = idDataType(plan.idType) as ColumnDataType;
168
+ builder =
169
+ plan.idType === "integer"
170
+ ? builder.addColumn("id", idType, (c) =>
171
+ dialect === "postgres"
172
+ ? c.primaryKey().generatedAlwaysAsIdentity()
173
+ : c.primaryKey().autoIncrement(),
174
+ )
175
+ : builder.addColumn("id", idType, (c) => c.primaryKey().notNull());
176
+ if (plan.timestamps) {
177
+ builder = builder.addColumn("created_at", "text").addColumn("updated_at", "text");
178
+ }
179
+ for (const col of plan.columns) {
180
+ builder = builder.addColumn(col.column, col.dataType as ColumnDataType);
181
+ }
182
+ return builder;
183
+ }
184
+
185
+ /** Names of existing indexes (dialect-specific catalog queries; Kysely has no index introspection). */
186
+ async function indexNames(
187
+ db: Kysely<DynamicDatabase>,
188
+ dialect: SqlDialectKind,
189
+ ): Promise<Set<string>> {
190
+ try {
191
+ const query =
192
+ dialect === "postgres"
193
+ ? sql<{ name: string }>`select indexname as name from pg_indexes`
194
+ : sql<{ name: string }>`select name from sqlite_master where type = 'index'`;
195
+ const { rows } = await query.execute(db);
196
+ return new Set(rows.map((r) => r.name));
197
+ } catch {
198
+ // No catalog access (exotic dialect): fall back to emitting CREATE INDEX unconditionally.
199
+ return new Set();
200
+ }
201
+ }