@studiocms/migrator 0.0.0-beta.0 → 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/LICENSE +21 -0
- package/README.md +63 -0
- package/package.json +55 -7
- package/public/favicon.svg +5 -0
- package/src/components/ErrorCard.astro +20 -0
- package/src/components/MigrationMeta.astro +74 -0
- package/src/components/MigrationStatus.astro +71 -0
- package/src/components/PageHeader.astro +24 -0
- package/src/components/StatusTables.astro +45 -0
- package/src/db/astro-db-drizzle-client.ts +14 -0
- package/src/db/astro-db-schema.ts +193 -0
- package/src/db/astrodb.ts +88 -0
- package/src/db/client.ts +156 -0
- package/src/db/drizzle-schema.ts +54 -0
- package/src/env.d.ts +3 -0
- package/src/fonts/css/onest-variable.css +16 -0
- package/src/fonts/woff2/onest-variable.woff2 +0 -0
- package/src/layouts/Layout.astro +30 -0
- package/src/lib/astro-db-drizzle-compat/core-types.ts +88 -0
- package/src/lib/astro-db-drizzle-compat/error-map.ts +105 -0
- package/src/lib/astro-db-drizzle-compat/index.ts +149 -0
- package/src/lib/astro-db-drizzle-compat/schemas.ts +249 -0
- package/src/lib/astro-db-drizzle-compat/types.ts +141 -0
- package/src/lib/astro-db-drizzle-compat/utils.ts +55 -0
- package/src/lib/astro-db-drizzle-compat/virtual.ts +91 -0
- package/src/lib/errors.ts +57 -0
- package/src/lib/logger.ts +4 -0
- package/src/lib/remapUtils.ts +170 -0
- package/src/lib/response-utils.ts +12 -0
- package/src/lib/tableMap.ts +236 -0
- package/src/pages/data-migrations.ts +268 -0
- package/src/pages/index.astro +259 -0
- package/src/pages/schema-migrations.ts +31 -0
- package/src/styles/global.css +165 -0
- package/start.mjs +163 -0
- package/utils/logger.mjs +93 -0
- package/utils/resolver.mjs +60 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type AstroDBTableKeys, astroDbTableKeys } from '../lib/tableMap';
|
|
2
|
+
import { db, sql } from './astro-db-drizzle-client.js';
|
|
3
|
+
|
|
4
|
+
export const runConnectionTest = async () => {
|
|
5
|
+
try {
|
|
6
|
+
await db.$client.execute('SELECT CURRENT_TIME');
|
|
7
|
+
return true;
|
|
8
|
+
} catch (_error) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Search for tables in a LibSQL database by name using Drizzle
|
|
15
|
+
* @param searchPattern - Search pattern (supports wildcards: %)
|
|
16
|
+
* @returns Array of matching table names
|
|
17
|
+
*/
|
|
18
|
+
export async function searchTables(searchPattern: string): Promise<string[]> {
|
|
19
|
+
const result = await db.all<{ name: string }>(
|
|
20
|
+
sql`
|
|
21
|
+
SELECT name
|
|
22
|
+
FROM sqlite_master
|
|
23
|
+
WHERE type = 'table'
|
|
24
|
+
AND name LIKE ${searchPattern}
|
|
25
|
+
AND name NOT LIKE 'sqlite_%'
|
|
26
|
+
AND name != '_astro_db_snapshot'
|
|
27
|
+
ORDER BY name
|
|
28
|
+
`
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return result.map((row) => row.name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get all tables in the database
|
|
36
|
+
* @returns Array of all table names
|
|
37
|
+
*/
|
|
38
|
+
export async function getAllTables(): Promise<string[]> {
|
|
39
|
+
return searchTables('%');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if a specific table exists
|
|
44
|
+
* @param tableName - Exact table name to check
|
|
45
|
+
* @returns Boolean indicating if table exists
|
|
46
|
+
*/
|
|
47
|
+
export async function tableExists(tableName: string): Promise<boolean> {
|
|
48
|
+
const tables = await searchTables(tableName);
|
|
49
|
+
return tables.length > 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const getTableLength = async (table: AstroDBTableKeys) => {
|
|
53
|
+
const result = await db.get<{ count: number }>(
|
|
54
|
+
sql`SELECT COUNT(*) as count FROM ${sql.raw(table)}`
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return result?.count ?? 0;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const getDataMigrationMeta = async () => {
|
|
61
|
+
const tableLengths: Record<AstroDBTableKeys, number> = {} as Record<AstroDBTableKeys, number>;
|
|
62
|
+
|
|
63
|
+
for (const table of astroDbTableKeys) {
|
|
64
|
+
const result = await getTableLength(table).catch(() => 0);
|
|
65
|
+
tableLengths[table] = result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return tableLengths;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Usage examples:
|
|
72
|
+
// async function examples() {
|
|
73
|
+
// // Search for tables starting with "user"
|
|
74
|
+
// const userTables = await searchTables('user%');
|
|
75
|
+
// console.log('Tables starting with "user":', userTables);
|
|
76
|
+
|
|
77
|
+
// // Search for tables containing "post"
|
|
78
|
+
// const postTables = await searchTables('%post%');
|
|
79
|
+
// console.log('Tables containing "post":', postTables);
|
|
80
|
+
|
|
81
|
+
// // Get all tables
|
|
82
|
+
// const allTables = await getAllTables();
|
|
83
|
+
// console.log('All tables:', allTables);
|
|
84
|
+
|
|
85
|
+
// // Check if specific table exists
|
|
86
|
+
// const exists = await tableExists('users');
|
|
87
|
+
// console.log('Table "users" exists:', exists);
|
|
88
|
+
// }
|
package/src/db/client.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { runEffect } from '@withstudiocms/effect';
|
|
2
|
+
import { getDBClientLive } from '@withstudiocms/kysely/client';
|
|
3
|
+
import { sql } from '@withstudiocms/kysely/kysely';
|
|
4
|
+
import { getMigratorLive } from '@withstudiocms/sdk/migrator';
|
|
5
|
+
import type { StudioCMSDatabaseSchema } from '@withstudiocms/sdk/tables';
|
|
6
|
+
import { type KyselyTableKeys, kyselyTableKeys } from '../lib/tableMap';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Dynamically import and return the database driver based on the provided dialect.
|
|
10
|
+
*
|
|
11
|
+
* @param dialect - The database dialect as a string.
|
|
12
|
+
* @returns The database driver.
|
|
13
|
+
* @throws Error if the dialect is unsupported.
|
|
14
|
+
*/
|
|
15
|
+
async function getDialectDriver(dialect: string | undefined) {
|
|
16
|
+
switch (dialect) {
|
|
17
|
+
case 'libsql': {
|
|
18
|
+
const mod = await import('@withstudiocms/kysely/drivers/libsql');
|
|
19
|
+
return await runEffect(mod.libsqlDriver);
|
|
20
|
+
}
|
|
21
|
+
case 'postgres': {
|
|
22
|
+
const mod = await import('@withstudiocms/kysely/drivers/postgres');
|
|
23
|
+
return await runEffect(mod.postgresDriver);
|
|
24
|
+
}
|
|
25
|
+
case 'mysql': {
|
|
26
|
+
const mod = await import('@withstudiocms/kysely/drivers/mysql');
|
|
27
|
+
return await runEffect(mod.mysqlDriver);
|
|
28
|
+
}
|
|
29
|
+
default:
|
|
30
|
+
throw new Error(`Unsupported STUDIOCMS_DIALECT: ${dialect}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get a StudioCMS database client based on the STUDIOCMS_DIALECT environment variable.
|
|
36
|
+
*
|
|
37
|
+
* @returns The StudioCMS database client.
|
|
38
|
+
* @throws Error if the STUDIOCMS_DIALECT environment variable is not set or unsupported.
|
|
39
|
+
*/
|
|
40
|
+
export const getStudioCMSDb = async () => {
|
|
41
|
+
const dialect = process.env.STUDIOCMS_DIALECT;
|
|
42
|
+
if (!dialect) {
|
|
43
|
+
throw new Error('STUDIOCMS_DIALECT environment variable is not set.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const driver = await getDialectDriver(dialect);
|
|
47
|
+
return await runEffect(getDBClientLive<StudioCMSDatabaseSchema>(driver));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get a StudioCMS database migrator based on the STUDIOCMS_DIALECT environment variable.
|
|
52
|
+
*
|
|
53
|
+
* @returns The StudioCMS database migrator.
|
|
54
|
+
* @throws Error if the STUDIOCMS_DIALECT environment variable is not set or unsupported.
|
|
55
|
+
*/
|
|
56
|
+
export const studioCMSDbMigrator = async () => {
|
|
57
|
+
const dialect = process.env.STUDIOCMS_DIALECT;
|
|
58
|
+
if (!dialect) {
|
|
59
|
+
throw new Error('STUDIOCMS_DIALECT environment variable is not set.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const driver = await getDialectDriver(dialect);
|
|
63
|
+
return await runEffect(getMigratorLive(driver));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run a connection test to the StudioCMS database.
|
|
68
|
+
*
|
|
69
|
+
* @returns Boolean indicating if the connection test was successful.
|
|
70
|
+
*/
|
|
71
|
+
export const runConnectionTest = async () => {
|
|
72
|
+
const { db } = await getStudioCMSDb();
|
|
73
|
+
try {
|
|
74
|
+
await db.executeQuery(sql`SELECT CURRENT_TIME;`.compile(db));
|
|
75
|
+
return true;
|
|
76
|
+
} catch (_e) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Map of supported database dialects to their display names.
|
|
83
|
+
*/
|
|
84
|
+
const dialectMap = {
|
|
85
|
+
libsql: 'LibSQL',
|
|
86
|
+
mysql: 'MySQL',
|
|
87
|
+
postgres: 'PostgreSQL',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the database dialect from the environment variable.
|
|
92
|
+
*
|
|
93
|
+
* @returns The display name of the database dialect.
|
|
94
|
+
*/
|
|
95
|
+
export const getDialectFromEnv = () => {
|
|
96
|
+
return dialectMap[process.env.STUDIOCMS_DIALECT as keyof typeof dialectMap] || 'Unknown';
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the number of rows in a specified table.
|
|
101
|
+
*
|
|
102
|
+
* @param table - The name of the table.
|
|
103
|
+
* @returns The number of rows in the table.
|
|
104
|
+
*/
|
|
105
|
+
export const getTableLength = async (table: KyselyTableKeys) => {
|
|
106
|
+
const { db } = await getStudioCMSDb();
|
|
107
|
+
const result = await db.selectFrom(table).select(sql`COUNT(*)`.as('count')).executeTakeFirst();
|
|
108
|
+
|
|
109
|
+
if (!result) {
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return Number(result.count);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check the data migration status by verifying if any tables contain rows.
|
|
118
|
+
*
|
|
119
|
+
* @returns An object indicating whether migration can proceed.
|
|
120
|
+
*/
|
|
121
|
+
export const getDataMigrationStatus = async () => {
|
|
122
|
+
const tablesWithRows: string[] = [];
|
|
123
|
+
|
|
124
|
+
for (const table of kyselyTableKeys) {
|
|
125
|
+
const result = await getTableLength(table).catch(() => 0);
|
|
126
|
+
if (result > 0) {
|
|
127
|
+
tablesWithRows.push(table);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (tablesWithRows.length > 0) {
|
|
132
|
+
return {
|
|
133
|
+
canMigrate: false,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
canMigrate: true,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get metadata about the data migration, including the number of rows in each table.
|
|
144
|
+
*
|
|
145
|
+
* @returns An object mapping table names to their respective row counts.
|
|
146
|
+
*/
|
|
147
|
+
export const getDataMigrationMeta = async () => {
|
|
148
|
+
const tableLengths: Record<KyselyTableKeys, number> = {} as Record<KyselyTableKeys, number>;
|
|
149
|
+
|
|
150
|
+
for (const table of kyselyTableKeys) {
|
|
151
|
+
const result = await getTableLength(table).catch(() => 0);
|
|
152
|
+
tableLengths[table] = result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return tableLengths;
|
|
156
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { asDrizzleTable } from '../lib/astro-db-drizzle-compat/index.js';
|
|
2
|
+
import * as Schema from './astro-db-schema.js';
|
|
3
|
+
|
|
4
|
+
export const StudioCMSUsers = asDrizzleTable('StudioCMSUsers', Schema.StudioCMSUsers);
|
|
5
|
+
export const StudioCMSPageData = asDrizzleTable('StudioCMSPageData', Schema.StudioCMSPageData);
|
|
6
|
+
export const StudioCMSPageFolderStructure = asDrizzleTable(
|
|
7
|
+
'StudioCMSPageFolderStructure',
|
|
8
|
+
Schema.StudioCMSPageFolderStructure
|
|
9
|
+
);
|
|
10
|
+
export const StudioCMSPageDataTags = asDrizzleTable(
|
|
11
|
+
'StudioCMSPageDataTags',
|
|
12
|
+
Schema.StudioCMSPageDataTags
|
|
13
|
+
);
|
|
14
|
+
export const StudioCMSPageDataCategories = asDrizzleTable(
|
|
15
|
+
'StudioCMSPageDataCategories',
|
|
16
|
+
Schema.StudioCMSPageDataCategories
|
|
17
|
+
);
|
|
18
|
+
export const StudioCMSPluginData = asDrizzleTable(
|
|
19
|
+
'StudioCMSPluginData',
|
|
20
|
+
Schema.StudioCMSPluginData
|
|
21
|
+
);
|
|
22
|
+
export const StudioCMSDynamicConfigSettings = asDrizzleTable(
|
|
23
|
+
'StudioCMSDynamicConfigSettings',
|
|
24
|
+
Schema.StudioCMSDynamicConfigSettings
|
|
25
|
+
);
|
|
26
|
+
export const StudioCMSAPIKeys = asDrizzleTable('StudioCMSAPIKeys', Schema.StudioCMSAPIKeys);
|
|
27
|
+
export const StudioCMSUserResetTokens = asDrizzleTable(
|
|
28
|
+
'StudioCMSUserResetTokens',
|
|
29
|
+
Schema.StudioCMSUserResetTokens
|
|
30
|
+
);
|
|
31
|
+
export const StudioCMSOAuthAccounts = asDrizzleTable(
|
|
32
|
+
'StudioCMSOAuthAccounts',
|
|
33
|
+
Schema.StudioCMSOAuthAccounts
|
|
34
|
+
);
|
|
35
|
+
export const StudioCMSSessionTable = asDrizzleTable(
|
|
36
|
+
'StudioCMSSessionTable',
|
|
37
|
+
Schema.StudioCMSSessionTable
|
|
38
|
+
);
|
|
39
|
+
export const StudioCMSPermissions = asDrizzleTable(
|
|
40
|
+
'StudioCMSPermissions',
|
|
41
|
+
Schema.StudioCMSPermissions
|
|
42
|
+
);
|
|
43
|
+
export const StudioCMSDiffTracking = asDrizzleTable(
|
|
44
|
+
'StudioCMSDiffTracking',
|
|
45
|
+
Schema.StudioCMSDiffTracking
|
|
46
|
+
);
|
|
47
|
+
export const StudioCMSPageContent = asDrizzleTable(
|
|
48
|
+
'StudioCMSPageContent',
|
|
49
|
+
Schema.StudioCMSPageContent
|
|
50
|
+
);
|
|
51
|
+
export const StudioCMSEmailVerificationTokens = asDrizzleTable(
|
|
52
|
+
'StudioCMSEmailVerificationTokens',
|
|
53
|
+
Schema.StudioCMSEmailVerificationTokens
|
|
54
|
+
);
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/* Onest Variable Font from Fontsource */
|
|
2
|
+
@font-face {
|
|
3
|
+
font-family: "Onest Variable";
|
|
4
|
+
font-style: normal;
|
|
5
|
+
font-display: swap;
|
|
6
|
+
font-weight: 100 900;
|
|
7
|
+
src: url("../woff2/onest-variable.woff2") format("woff2-variations");
|
|
8
|
+
unicode-range:
|
|
9
|
+
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* CSS variable with Onest Variable font and system font fallback */
|
|
13
|
+
:root {
|
|
14
|
+
--scms-font-onest:
|
|
15
|
+
"Onest Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
|
16
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Toaster } from 'studiocms:ui/components/toaster';
|
|
3
|
+
import PageHeader from '../components/PageHeader.astro';
|
|
4
|
+
import '../fonts/css/onest-variable.css';
|
|
5
|
+
import '../styles/global.css';
|
|
6
|
+
import 'studiocms:ui/prose';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
title: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { title } = Astro.props;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="utf-8" />
|
|
18
|
+
<meta name="viewport" content="width=device-width" />
|
|
19
|
+
<meta name="generator" content={Astro.generator} />
|
|
20
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
21
|
+
<title>{title}</title>
|
|
22
|
+
</head>
|
|
23
|
+
<body>
|
|
24
|
+
<Toaster />
|
|
25
|
+
<main>
|
|
26
|
+
<PageHeader />
|
|
27
|
+
<slot />
|
|
28
|
+
</main>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { z } from 'astro/zod';
|
|
2
|
+
import type {
|
|
3
|
+
booleanColumnSchema,
|
|
4
|
+
columnSchema,
|
|
5
|
+
columnsSchema,
|
|
6
|
+
dateColumnSchema,
|
|
7
|
+
dbConfigSchema,
|
|
8
|
+
indexSchema,
|
|
9
|
+
jsonColumnSchema,
|
|
10
|
+
MaybeArray,
|
|
11
|
+
numberColumnOptsSchema,
|
|
12
|
+
numberColumnSchema,
|
|
13
|
+
referenceableColumnSchema,
|
|
14
|
+
resolvedIndexSchema,
|
|
15
|
+
tableSchema,
|
|
16
|
+
textColumnOptsSchema,
|
|
17
|
+
textColumnSchema,
|
|
18
|
+
} from './schemas.js';
|
|
19
|
+
|
|
20
|
+
export type ResolvedIndexes = z.output<typeof dbConfigSchema>['tables'][string]['indexes'];
|
|
21
|
+
export type BooleanColumn = z.infer<typeof booleanColumnSchema>;
|
|
22
|
+
export type BooleanColumnInput = z.input<typeof booleanColumnSchema>;
|
|
23
|
+
export type NumberColumn = z.infer<typeof numberColumnSchema>;
|
|
24
|
+
export type NumberColumnInput = z.input<typeof numberColumnSchema>;
|
|
25
|
+
export type TextColumn = z.infer<typeof textColumnSchema>;
|
|
26
|
+
export type TextColumnInput = z.input<typeof textColumnSchema>;
|
|
27
|
+
export type DateColumn = z.infer<typeof dateColumnSchema>;
|
|
28
|
+
export type DateColumnInput = z.input<typeof dateColumnSchema>;
|
|
29
|
+
export type JsonColumn = z.infer<typeof jsonColumnSchema>;
|
|
30
|
+
export type JsonColumnInput = z.input<typeof jsonColumnSchema>;
|
|
31
|
+
|
|
32
|
+
export type ColumnType =
|
|
33
|
+
| BooleanColumn['type']
|
|
34
|
+
| NumberColumn['type']
|
|
35
|
+
| TextColumn['type']
|
|
36
|
+
| DateColumn['type']
|
|
37
|
+
| JsonColumn['type'];
|
|
38
|
+
|
|
39
|
+
export type DBColumn = z.infer<typeof columnSchema>;
|
|
40
|
+
export type DBColumnInput =
|
|
41
|
+
| DateColumnInput
|
|
42
|
+
| BooleanColumnInput
|
|
43
|
+
| NumberColumnInput
|
|
44
|
+
| TextColumnInput
|
|
45
|
+
| JsonColumnInput;
|
|
46
|
+
export type DBColumns = z.infer<typeof columnsSchema>;
|
|
47
|
+
export type DBTable = z.infer<typeof tableSchema>;
|
|
48
|
+
export type DBTables = Record<string, DBTable>;
|
|
49
|
+
export type ResolvedDBTables = z.output<typeof dbConfigSchema>['tables'];
|
|
50
|
+
export type ResolvedDBTable = z.output<typeof dbConfigSchema>['tables'][string];
|
|
51
|
+
export type DBSnapshot = {
|
|
52
|
+
schema: Record<string, ResolvedDBTable>;
|
|
53
|
+
version: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type DBConfigInput = z.input<typeof dbConfigSchema>;
|
|
57
|
+
export type DBConfig = z.infer<typeof dbConfigSchema>;
|
|
58
|
+
|
|
59
|
+
export type ColumnsConfig = z.input<typeof tableSchema>['columns'];
|
|
60
|
+
export type OutputColumnsConfig = z.output<typeof tableSchema>['columns'];
|
|
61
|
+
|
|
62
|
+
export interface TableConfig<TColumns extends ColumnsConfig = ColumnsConfig>
|
|
63
|
+
// use `extends` to ensure types line up with zod,
|
|
64
|
+
// only adding generics for type completions.
|
|
65
|
+
extends Pick<z.input<typeof tableSchema>, 'columns' | 'indexes' | 'foreignKeys'> {
|
|
66
|
+
columns: TColumns;
|
|
67
|
+
foreignKeys?: Array<{
|
|
68
|
+
columns: MaybeArray<Extract<keyof TColumns, string>>;
|
|
69
|
+
references: () => MaybeArray<z.input<typeof referenceableColumnSchema>>;
|
|
70
|
+
}>;
|
|
71
|
+
indexes?: Array<IndexConfig<TColumns>> | Record<string, LegacyIndexConfig<TColumns>>;
|
|
72
|
+
deprecated?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface IndexConfig<TColumns extends ColumnsConfig> extends z.input<typeof indexSchema> {
|
|
76
|
+
on: MaybeArray<Extract<keyof TColumns, string>>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @deprecated */
|
|
80
|
+
interface LegacyIndexConfig<TColumns extends ColumnsConfig>
|
|
81
|
+
extends z.input<typeof resolvedIndexSchema> {
|
|
82
|
+
on: MaybeArray<Extract<keyof TColumns, string>>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// We cannot use `Omit<NumberColumn | TextColumn, 'type'>`,
|
|
86
|
+
// since Omit collapses our union type on primary key.
|
|
87
|
+
export type NumberColumnOpts = z.input<typeof numberColumnOptsSchema>;
|
|
88
|
+
export type TextColumnOpts = z.input<typeof textColumnOptsSchema>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This is a modified version of Astro's error map. source:
|
|
3
|
+
* https://github.com/withastro/astro/blob/main/packages/astro/src/content/error-map.ts
|
|
4
|
+
*/
|
|
5
|
+
/** biome-ignore-all lint/suspicious/noExplicitAny: source code from `@astrojs/db` */
|
|
6
|
+
import type { z } from 'astro/zod';
|
|
7
|
+
|
|
8
|
+
interface TypeOrLiteralErrByPathEntry {
|
|
9
|
+
code: 'invalid_type' | 'invalid_literal';
|
|
10
|
+
received: unknown;
|
|
11
|
+
expected: unknown[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const errorMap: z.ZodErrorMap = (baseError, ctx) => {
|
|
15
|
+
const baseErrorPath = flattenErrorPath(baseError.path);
|
|
16
|
+
if (baseError.code === 'invalid_union') {
|
|
17
|
+
// Optimization: Combine type and literal errors for keys that are common across ALL union types
|
|
18
|
+
// Ex. a union between `{ key: z.literal('tutorial') }` and `{ key: z.literal('blog') }` will
|
|
19
|
+
// raise a single error when `key` does not match:
|
|
20
|
+
// > Did not match union.
|
|
21
|
+
// > key: Expected `'tutorial' | 'blog'`, received 'foo'
|
|
22
|
+
const typeOrLiteralErrByPath = new Map<string, TypeOrLiteralErrByPathEntry>();
|
|
23
|
+
for (const unionError of baseError.unionErrors.flatMap((e) => e.errors)) {
|
|
24
|
+
if (unionError.code === 'invalid_type' || unionError.code === 'invalid_literal') {
|
|
25
|
+
const flattenedErrorPath = flattenErrorPath(unionError.path);
|
|
26
|
+
const typeOrLiteralErr = typeOrLiteralErrByPath.get(flattenedErrorPath);
|
|
27
|
+
if (typeOrLiteralErr) {
|
|
28
|
+
typeOrLiteralErr.expected.push(unionError.expected);
|
|
29
|
+
} else {
|
|
30
|
+
typeOrLiteralErrByPath.set(flattenedErrorPath, {
|
|
31
|
+
code: unionError.code,
|
|
32
|
+
received: (unionError as any).received,
|
|
33
|
+
expected: [unionError.expected],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const messages: string[] = [
|
|
39
|
+
prefix(
|
|
40
|
+
baseErrorPath,
|
|
41
|
+
typeOrLiteralErrByPath.size ? 'Did not match union:' : 'Did not match union.'
|
|
42
|
+
),
|
|
43
|
+
];
|
|
44
|
+
return {
|
|
45
|
+
message: messages
|
|
46
|
+
.concat(
|
|
47
|
+
[...typeOrLiteralErrByPath.entries()]
|
|
48
|
+
// If type or literal error isn't common to ALL union types,
|
|
49
|
+
// filter it out. Can lead to confusing noise.
|
|
50
|
+
.filter(([, error]) => error.expected.length === baseError.unionErrors.length)
|
|
51
|
+
.map(([key, error]) =>
|
|
52
|
+
// Avoid printing the key again if it's a base error
|
|
53
|
+
key === baseErrorPath
|
|
54
|
+
? `> ${getTypeOrLiteralMsg(error)}`
|
|
55
|
+
: `> ${prefix(key, getTypeOrLiteralMsg(error))}`
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
.join('\n'),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (baseError.code === 'invalid_literal' || baseError.code === 'invalid_type') {
|
|
62
|
+
return {
|
|
63
|
+
message: prefix(
|
|
64
|
+
baseErrorPath,
|
|
65
|
+
getTypeOrLiteralMsg({
|
|
66
|
+
code: baseError.code,
|
|
67
|
+
received: (baseError as any).received,
|
|
68
|
+
expected: [baseError.expected],
|
|
69
|
+
})
|
|
70
|
+
),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (baseError.message) {
|
|
74
|
+
return { message: prefix(baseErrorPath, baseError.message) };
|
|
75
|
+
}
|
|
76
|
+
return { message: prefix(baseErrorPath, ctx.defaultError) };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const getTypeOrLiteralMsg = (error: TypeOrLiteralErrByPathEntry): string => {
|
|
80
|
+
if (error.received === 'undefined') return 'Required';
|
|
81
|
+
const expectedDeduped = new Set(error.expected);
|
|
82
|
+
switch (error.code) {
|
|
83
|
+
case 'invalid_type':
|
|
84
|
+
return `Expected type \`${unionExpectedVals(expectedDeduped)}\`, received ${JSON.stringify(
|
|
85
|
+
error.received
|
|
86
|
+
)}`;
|
|
87
|
+
case 'invalid_literal':
|
|
88
|
+
return `Expected \`${unionExpectedVals(expectedDeduped)}\`, received ${JSON.stringify(
|
|
89
|
+
error.received
|
|
90
|
+
)}`;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const prefix = (key: string, msg: string) => (key.length ? `**${key}**: ${msg}` : msg);
|
|
95
|
+
|
|
96
|
+
const unionExpectedVals = (expectedVals: Set<unknown>) =>
|
|
97
|
+
[...expectedVals]
|
|
98
|
+
.map((expectedVal, idx) => {
|
|
99
|
+
if (idx === 0) return JSON.stringify(expectedVal);
|
|
100
|
+
const sep = ' | ';
|
|
101
|
+
return `${sep}${JSON.stringify(expectedVal)}`;
|
|
102
|
+
})
|
|
103
|
+
.join('');
|
|
104
|
+
|
|
105
|
+
const flattenErrorPath = (errorPath: Array<string | number>) => errorPath.join('.');
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type ColumnBuilderBaseConfig, type ColumnDataType, sql } from 'drizzle-orm';
|
|
2
|
+
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
|
3
|
+
import {
|
|
4
|
+
customType,
|
|
5
|
+
type IndexBuilder,
|
|
6
|
+
index,
|
|
7
|
+
integer,
|
|
8
|
+
type SQLiteColumnBuilderBase,
|
|
9
|
+
sqliteTable,
|
|
10
|
+
text,
|
|
11
|
+
} from 'drizzle-orm/sqlite-core';
|
|
12
|
+
import type { ColumnsConfig, DBColumn, DBTable, TableConfig } from './core-types.js';
|
|
13
|
+
import { tableSchema } from './schemas.js';
|
|
14
|
+
import { isSerializedSQL, type SerializedSQL, type Table } from './types.js';
|
|
15
|
+
import { hasPrimaryKey } from './utils.js';
|
|
16
|
+
export type Database = LibSQLDatabase;
|
|
17
|
+
export type { Table } from './types.js';
|
|
18
|
+
export { hasPrimaryKey } from './utils.js';
|
|
19
|
+
|
|
20
|
+
// Taken from:
|
|
21
|
+
// https://stackoverflow.com/questions/52869695/check-if-a-date-string-is-in-iso-and-utc-format
|
|
22
|
+
const isISODateString = (str: string) => /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(str);
|
|
23
|
+
|
|
24
|
+
const dateType = customType<{ data: Date; driverData: string }>({
|
|
25
|
+
dataType() {
|
|
26
|
+
return 'text';
|
|
27
|
+
},
|
|
28
|
+
toDriver(value) {
|
|
29
|
+
return value.toISOString();
|
|
30
|
+
},
|
|
31
|
+
fromDriver(value) {
|
|
32
|
+
if (!isISODateString(value)) {
|
|
33
|
+
// values saved using CURRENT_TIMESTAMP are not valid ISO strings
|
|
34
|
+
// but *are* in UTC, so append the UTC zone.
|
|
35
|
+
value += 'Z';
|
|
36
|
+
}
|
|
37
|
+
return new Date(value);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const jsonType = customType<{ data: unknown; driverData: string }>({
|
|
42
|
+
dataType() {
|
|
43
|
+
return 'text';
|
|
44
|
+
},
|
|
45
|
+
toDriver(value) {
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
},
|
|
48
|
+
fromDriver(value) {
|
|
49
|
+
return JSON.parse(value);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
type D1ColumnBuilder = SQLiteColumnBuilderBase<
|
|
54
|
+
ColumnBuilderBaseConfig<ColumnDataType, string> & { data: unknown }
|
|
55
|
+
>;
|
|
56
|
+
|
|
57
|
+
export function internal_asDrizzleTable(name: string, table: DBTable) {
|
|
58
|
+
const columns: Record<string, D1ColumnBuilder> = {};
|
|
59
|
+
if (!Object.entries(table.columns).some(([, column]) => hasPrimaryKey(column))) {
|
|
60
|
+
columns._id = integer('_id').primaryKey();
|
|
61
|
+
}
|
|
62
|
+
for (const [columnName, column] of Object.entries(table.columns)) {
|
|
63
|
+
columns[columnName] = columnMapper(columnName, column);
|
|
64
|
+
}
|
|
65
|
+
const drizzleTable = sqliteTable(name, columns, (ormTable) => {
|
|
66
|
+
const indexes: Array<IndexBuilder> = [];
|
|
67
|
+
for (const [indexName, indexProps] of Object.entries(table.indexes ?? {})) {
|
|
68
|
+
const onColNames = Array.isArray(indexProps.on) ? indexProps.on : [indexProps.on];
|
|
69
|
+
const onCols = onColNames.map((colName) => ormTable[colName]);
|
|
70
|
+
if (!atLeastOne(onCols)) continue;
|
|
71
|
+
|
|
72
|
+
indexes.push(index(indexName).on(...onCols));
|
|
73
|
+
}
|
|
74
|
+
return indexes;
|
|
75
|
+
});
|
|
76
|
+
return drizzleTable;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function asDrizzleTable<
|
|
80
|
+
TableName extends string = string,
|
|
81
|
+
TColumns extends ColumnsConfig = ColumnsConfig,
|
|
82
|
+
>(name: TableName, tableConfig: TableConfig<TColumns>) {
|
|
83
|
+
return internal_asDrizzleTable(name, tableSchema.parse(tableConfig)) as Table<
|
|
84
|
+
TableName,
|
|
85
|
+
TColumns
|
|
86
|
+
>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function atLeastOne<T>(arr: T[]): arr is [T, ...T[]] {
|
|
90
|
+
return arr.length > 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function columnMapper(columnName: string, column: DBColumn) {
|
|
94
|
+
let c: ReturnType<
|
|
95
|
+
| typeof text
|
|
96
|
+
| typeof integer
|
|
97
|
+
| typeof jsonType
|
|
98
|
+
| typeof dateType
|
|
99
|
+
| typeof integer<string, 'boolean'>
|
|
100
|
+
>;
|
|
101
|
+
|
|
102
|
+
switch (column.type) {
|
|
103
|
+
case 'text': {
|
|
104
|
+
c = text(columnName, { enum: column.schema.enum });
|
|
105
|
+
// Duplicate default logic across cases to preserve type inference.
|
|
106
|
+
// No clean generic for every column builder.
|
|
107
|
+
if (column.schema.default !== undefined)
|
|
108
|
+
c = c.default(handleSerializedSQL(column.schema.default));
|
|
109
|
+
if (column.schema.primaryKey === true) c = c.primaryKey();
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case 'number': {
|
|
113
|
+
c = integer(columnName);
|
|
114
|
+
if (column.schema.default !== undefined)
|
|
115
|
+
c = c.default(handleSerializedSQL(column.schema.default));
|
|
116
|
+
if (column.schema.primaryKey === true) c = c.primaryKey();
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case 'boolean': {
|
|
120
|
+
c = integer(columnName, { mode: 'boolean' });
|
|
121
|
+
if (column.schema.default !== undefined)
|
|
122
|
+
c = c.default(handleSerializedSQL(column.schema.default));
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case 'json':
|
|
126
|
+
c = jsonType(columnName);
|
|
127
|
+
if (column.schema.default !== undefined) c = c.default(column.schema.default);
|
|
128
|
+
break;
|
|
129
|
+
case 'date': {
|
|
130
|
+
c = dateType(columnName);
|
|
131
|
+
if (column.schema.default !== undefined) {
|
|
132
|
+
const def = handleSerializedSQL(column.schema.default);
|
|
133
|
+
c = c.default(typeof def === 'string' ? new Date(def) : def);
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!column.schema.optional) c = c.notNull();
|
|
140
|
+
if (column.schema.unique) c = c.unique();
|
|
141
|
+
return c;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handleSerializedSQL<T>(def: T | SerializedSQL) {
|
|
145
|
+
if (isSerializedSQL(def)) {
|
|
146
|
+
return sql.raw(def.sql);
|
|
147
|
+
}
|
|
148
|
+
return def;
|
|
149
|
+
}
|