mirror of
https://github.com/withastro/astro.git
synced 2025-01-20 22:12:38 -05:00
Initial DB migration code
This commit is contained in:
parent
57ab578bc7
commit
062eb67238
16 changed files with 1032 additions and 0 deletions
0
packages/db/CHANGELOG.md
Normal file
0
packages/db/CHANGELOG.md
Normal file
14
packages/db/components/Renderer.astro
Normal file
14
packages/db/components/Renderer.astro
Normal file
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
import { Renderer as MarkdocRenderer } from '@astrojs/markdoc/components';
|
||||
import { Markdoc } from '@astrojs/markdoc/config';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const { content } = Astro.props;
|
||||
|
||||
const ast = Markdoc.parse(content);
|
||||
---
|
||||
|
||||
<MarkdocRenderer stringifiedAst={JSON.stringify(ast)} config={{}} />
|
1
packages/db/components/astro-env.d.ts
vendored
Normal file
1
packages/db/components/astro-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="astro/client" />
|
2
packages/db/components/index.ts
Normal file
2
packages/db/components/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
// @ts-expect-error: missing types
|
||||
export { default as Renderer } from './Renderer.astro';
|
7
packages/db/components/tsconfig.json
Normal file
7
packages/db/components/tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": ["."],
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true
|
||||
}
|
||||
}
|
76
packages/db/package.json
Normal file
76
packages/db/package.json
Normal file
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"name": "@astrojs/db",
|
||||
"version": "0.1.3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./components": "./components/index.ts",
|
||||
"./internal": {
|
||||
"import": "./dist/internal.js",
|
||||
"types": "./dist/internal.d.ts"
|
||||
},
|
||||
"./internal-local": {
|
||||
"import": "./dist/internal-local.js",
|
||||
"types": "./dist/internal-local.d.ts"
|
||||
}
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
".": [
|
||||
"./dist/index.d.ts"
|
||||
],
|
||||
"internal": [
|
||||
"./dist/internal.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"components"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "astro-scripts build \"src/**/*.ts\" && tsc --project tsconfig.build.json",
|
||||
"typecheck": "tsc -b",
|
||||
"prepack": "pnpm run build",
|
||||
"dev": "pnpm /^dev:/",
|
||||
"dev:build": "astro-scripts dev \"src/**/*.ts\"",
|
||||
"dev:types": "tsc --project tsconfig.build.json --watch",
|
||||
"test": "pnpm run build && mocha test/**/*.js",
|
||||
"test:match": "mocha test/**/*.js -g"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/cli-kit": "^0.3.0",
|
||||
"@astrojs/markdoc": "^0.5.2",
|
||||
"@libsql/client": "0.4.0-pre.5",
|
||||
"astro": "^3.6.0",
|
||||
"better-sqlite3": "^8.7.0",
|
||||
"circle-rhyme-yes-measure": "workspace:^",
|
||||
"drizzle-orm": "^0.28.6",
|
||||
"esbuild": "^0.19.4",
|
||||
"github-slugger": "^2.0.0",
|
||||
"kleur": "^4.1.5",
|
||||
"miniflare": "^3.20231002.1",
|
||||
"nanoid": "^5.0.1",
|
||||
"open": "^9.1.0",
|
||||
"ora": "^7.0.1",
|
||||
"prompts": "^2.4.2",
|
||||
"yargs-parser": "^21.1.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.3.6",
|
||||
"@types/mocha": "^10.0.2",
|
||||
"@types/prompts": "^2.4.5",
|
||||
"@types/yargs-parser": "^21.0.1",
|
||||
"chai": "^4.3.10",
|
||||
"mocha": "^10.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.4.11"
|
||||
}
|
||||
}
|
56
packages/db/src/config.ts
Normal file
56
packages/db/src/config.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import {
|
||||
type BooleanField,
|
||||
type DBFieldInput,
|
||||
type DateFieldInput,
|
||||
type JsonField,
|
||||
type NumberField,
|
||||
type TextField,
|
||||
type collectionSchema,
|
||||
collectionsSchema,
|
||||
} from 'somewhere';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const adjustedConfigSchema = z.object({
|
||||
collections: collectionsSchema.optional(),
|
||||
});
|
||||
|
||||
export type DBUserConfig = z.input<typeof adjustedConfigSchema>;
|
||||
|
||||
export const astroConfigWithDBValidator = z.object({
|
||||
db: adjustedConfigSchema.optional(),
|
||||
});
|
||||
|
||||
export function defineCollection(
|
||||
userConfig: z.input<typeof collectionSchema>,
|
||||
): z.input<typeof collectionSchema> {
|
||||
return userConfig;
|
||||
}
|
||||
|
||||
export type AstroConfigWithDB = z.infer<typeof astroConfigWithDBValidator>;
|
||||
|
||||
type FieldOpts<T extends DBFieldInput> = Omit<T, 'type'>;
|
||||
|
||||
const baseDefaults = {
|
||||
optional: false,
|
||||
unique: false,
|
||||
label: undefined,
|
||||
default: undefined,
|
||||
};
|
||||
|
||||
export const field = {
|
||||
number(opts: FieldOpts<NumberField> = {}): NumberField {
|
||||
return { type: 'number', ...baseDefaults, ...opts };
|
||||
},
|
||||
boolean(opts: FieldOpts<BooleanField> = {}): BooleanField {
|
||||
return { type: 'boolean', ...baseDefaults, ...opts };
|
||||
},
|
||||
text(opts: FieldOpts<TextField> = {}): TextField {
|
||||
return { type: 'text', multiline: false, ...baseDefaults, ...opts };
|
||||
},
|
||||
date(opts: FieldOpts<DateFieldInput> = {}): DateFieldInput {
|
||||
return { type: 'date', ...baseDefaults, ...opts };
|
||||
},
|
||||
json(opts: FieldOpts<JsonField> = {}): JsonField {
|
||||
return { type: 'json', ...baseDefaults, ...opts };
|
||||
},
|
||||
};
|
104
packages/db/src/error-map.ts
Normal file
104
packages/db/src/error-map.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* This is a modified version of Astro's error map. source:
|
||||
* https://github.com/withastro/astro/blob/main/packages/astro/src/content/error-map.ts
|
||||
*/
|
||||
import type { z } from 'astro/zod';
|
||||
|
||||
interface TypeOrLiteralErrByPathEntry {
|
||||
code: 'invalid_type' | 'invalid_literal';
|
||||
received: unknown;
|
||||
expected: unknown[];
|
||||
}
|
||||
|
||||
export const errorMap: z.ZodErrorMap = (baseError, ctx) => {
|
||||
const baseErrorPath = flattenErrorPath(baseError.path);
|
||||
if (baseError.code === 'invalid_union') {
|
||||
// Optimization: Combine type and literal errors for keys that are common across ALL union types
|
||||
// Ex. a union between `{ key: z.literal('tutorial') }` and `{ key: z.literal('blog') }` will
|
||||
// raise a single error when `key` does not match:
|
||||
// > Did not match union.
|
||||
// > key: Expected `'tutorial' | 'blog'`, received 'foo'
|
||||
const typeOrLiteralErrByPath = new Map<string, TypeOrLiteralErrByPathEntry>();
|
||||
for (const unionError of baseError.unionErrors.flatMap((e) => e.errors)) {
|
||||
if (unionError.code === 'invalid_type' || unionError.code === 'invalid_literal') {
|
||||
const flattenedErrorPath = flattenErrorPath(unionError.path);
|
||||
const typeOrLiteralErr = typeOrLiteralErrByPath.get(flattenedErrorPath);
|
||||
if (typeOrLiteralErr) {
|
||||
typeOrLiteralErr.expected.push(unionError.expected);
|
||||
} else {
|
||||
typeOrLiteralErrByPath.set(flattenedErrorPath, {
|
||||
code: unionError.code,
|
||||
received: (unionError as any).received,
|
||||
expected: [unionError.expected],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const messages: string[] = [
|
||||
prefix(
|
||||
baseErrorPath,
|
||||
typeOrLiteralErrByPath.size ? 'Did not match union:' : 'Did not match union.',
|
||||
),
|
||||
];
|
||||
return {
|
||||
message: messages
|
||||
.concat(
|
||||
[...typeOrLiteralErrByPath.entries()]
|
||||
// If type or literal error isn't common to ALL union types,
|
||||
// filter it out. Can lead to confusing noise.
|
||||
.filter(([, error]) => error.expected.length === baseError.unionErrors.length)
|
||||
.map(([key, error]) =>
|
||||
// Avoid printing the key again if it's a base error
|
||||
key === baseErrorPath
|
||||
? `> ${getTypeOrLiteralMsg(error)}`
|
||||
: `> ${prefix(key, getTypeOrLiteralMsg(error))}`,
|
||||
),
|
||||
)
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
if (baseError.code === 'invalid_literal' || baseError.code === 'invalid_type') {
|
||||
return {
|
||||
message: prefix(
|
||||
baseErrorPath,
|
||||
getTypeOrLiteralMsg({
|
||||
code: baseError.code,
|
||||
received: (baseError as any).received,
|
||||
expected: [baseError.expected],
|
||||
}),
|
||||
),
|
||||
};
|
||||
} else if (baseError.message) {
|
||||
return { message: prefix(baseErrorPath, baseError.message) };
|
||||
} else {
|
||||
return { message: prefix(baseErrorPath, ctx.defaultError) };
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeOrLiteralMsg = (error: TypeOrLiteralErrByPathEntry): string => {
|
||||
if (error.received === 'undefined') return 'Required';
|
||||
const expectedDeduped = new Set(error.expected);
|
||||
switch (error.code) {
|
||||
case 'invalid_type':
|
||||
return `Expected type \`${unionExpectedVals(expectedDeduped)}\`, received ${JSON.stringify(
|
||||
error.received,
|
||||
)}`;
|
||||
case 'invalid_literal':
|
||||
return `Expected \`${unionExpectedVals(expectedDeduped)}\`, received ${JSON.stringify(
|
||||
error.received,
|
||||
)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const prefix = (key: string, msg: string) => (key.length ? `**${key}**: ${msg}` : msg);
|
||||
|
||||
const unionExpectedVals = (expectedVals: Set<unknown>) =>
|
||||
[...expectedVals]
|
||||
.map((expectedVal, idx) => {
|
||||
if (idx === 0) return JSON.stringify(expectedVal);
|
||||
const sep = ' | ';
|
||||
return `${sep}${JSON.stringify(expectedVal)}`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const flattenErrorPath = (errorPath: Array<string | number>) => errorPath.join('.');
|
1
packages/db/src/index.ts
Normal file
1
packages/db/src/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { defineCollection, field } from './config.js';
|
25
packages/db/src/internal-local.ts
Normal file
25
packages/db/src/internal-local.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { createClient } from '@libsql/client';
|
||||
import type { DBCollections } from 'circle-rhyme-yes-measure';
|
||||
import { type SQL, sql } from 'drizzle-orm';
|
||||
import { LibSQLDatabase, drizzle } from 'drizzle-orm/libsql';
|
||||
import { getCreateTableQuery } from './cli/sync/queries.js';
|
||||
|
||||
export async function createLocalDb(collections: DBCollections) {
|
||||
const client = createClient({ url: ':memory:' });
|
||||
const db = drizzle(client);
|
||||
|
||||
await createDbTables(db, collections);
|
||||
return db;
|
||||
}
|
||||
|
||||
async function createDbTables(db: LibSQLDatabase, collections: DBCollections) {
|
||||
const setupQueries: SQL[] = [];
|
||||
for (const [name, collection] of Object.entries(collections)) {
|
||||
const dropQuery = sql.raw(`DROP TABLE IF EXISTS ${name}`);
|
||||
const createQuery = sql.raw(getCreateTableQuery(name, collection));
|
||||
setupQueries.push(dropQuery, createQuery);
|
||||
}
|
||||
for (const q of setupQueries) {
|
||||
await db.run(q);
|
||||
}
|
||||
}
|
112
packages/db/src/internal.ts
Normal file
112
packages/db/src/internal.ts
Normal file
|
@ -0,0 +1,112 @@
|
|||
import type { ColumnBaseConfig, ColumnDataType } from 'drizzle-orm';
|
||||
import type { SQLiteColumn, SQLiteTableWithColumns, TableConfig } from 'drizzle-orm/sqlite-core';
|
||||
import type { SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy';
|
||||
|
||||
export { collectionToTable, createDb } from 'circle-rhyme-yes-measure';
|
||||
|
||||
export {
|
||||
sql,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
ne,
|
||||
isNull,
|
||||
isNotNull,
|
||||
inArray,
|
||||
notInArray,
|
||||
exists,
|
||||
notExists,
|
||||
between,
|
||||
notBetween,
|
||||
like,
|
||||
notIlike,
|
||||
not,
|
||||
asc,
|
||||
desc,
|
||||
and,
|
||||
or,
|
||||
} from 'drizzle-orm';
|
||||
export type SqliteDB = SqliteRemoteDatabase;
|
||||
|
||||
export type AstroTable<T extends Pick<TableConfig, 'name' | 'columns'>> = SQLiteTableWithColumns<
|
||||
T & {
|
||||
schema: undefined;
|
||||
dialect: 'sqlite';
|
||||
}
|
||||
>;
|
||||
|
||||
type GeneratedConfig<T extends ColumnDataType> = Pick<
|
||||
ColumnBaseConfig<T, string>,
|
||||
'name' | 'tableName' | 'notNull' | 'hasDefault'
|
||||
>;
|
||||
|
||||
export type AstroText<T extends GeneratedConfig<'string'>> = SQLiteColumn<
|
||||
T & {
|
||||
data: string;
|
||||
dataType: 'string';
|
||||
columnType: 'SQLiteText';
|
||||
driverParam: string;
|
||||
enumValues: never;
|
||||
baseColumn: never;
|
||||
}
|
||||
>;
|
||||
|
||||
export type AstroDate<T extends GeneratedConfig<'custom'>> = SQLiteColumn<
|
||||
T & {
|
||||
data: Date;
|
||||
dataType: 'custom';
|
||||
columnType: 'SQLiteCustomColumn';
|
||||
driverParam: string;
|
||||
enumValues: never;
|
||||
baseColumn: never;
|
||||
}
|
||||
>;
|
||||
|
||||
export type AstroBoolean<T extends GeneratedConfig<'boolean'>> = SQLiteColumn<
|
||||
T & {
|
||||
data: boolean;
|
||||
dataType: 'boolean';
|
||||
columnType: 'SQLiteBoolean';
|
||||
driverParam: number;
|
||||
enumValues: never;
|
||||
baseColumn: never;
|
||||
}
|
||||
>;
|
||||
|
||||
export type AstroNumber<T extends GeneratedConfig<'number'>> = SQLiteColumn<
|
||||
T & {
|
||||
data: number;
|
||||
dataType: 'number';
|
||||
columnType: 'SQLiteInteger';
|
||||
driverParam: number;
|
||||
enumValues: never;
|
||||
baseColumn: never;
|
||||
}
|
||||
>;
|
||||
|
||||
export type AstroJson<T extends GeneratedConfig<'custom'>> = SQLiteColumn<
|
||||
T & {
|
||||
data: unknown;
|
||||
dataType: 'custom';
|
||||
columnType: 'SQLiteCustomColumn';
|
||||
driverParam: string;
|
||||
enumValues: never;
|
||||
baseColumn: never;
|
||||
}
|
||||
>;
|
||||
|
||||
export type AstroId<T extends Pick<GeneratedConfig<'string'>, 'tableName'>> = SQLiteColumn<
|
||||
T & {
|
||||
name: 'id';
|
||||
hasDefault: true;
|
||||
notNull: true;
|
||||
data: string;
|
||||
dataType: 'custom';
|
||||
columnType: 'SQLiteCustomColumn';
|
||||
driverParam: string;
|
||||
enumValues: never;
|
||||
baseColumn: never;
|
||||
}
|
||||
>;
|
101
packages/db/src/vite-plugin-db.ts
Normal file
101
packages/db/src/vite-plugin-db.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { DBCollections } from 'circle-rhyme-yes-measure';
|
||||
import { red } from 'kleur/colors';
|
||||
import {
|
||||
INTERNAL_LOCAL_PKG_IMP,
|
||||
INTERNAL_PKG_IMP,
|
||||
ROOT,
|
||||
SUPPORTED_SEED_FILES,
|
||||
VIRTUAL_MODULE_ID,
|
||||
drizzleFilterExps,
|
||||
} from './consts.js';
|
||||
import type { VitePlugin } from './utils.js';
|
||||
|
||||
const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
|
||||
|
||||
type Opts = { mode: 'dev' } | { mode: 'prod'; projectId: string; token: string };
|
||||
|
||||
export function vitePluginDb(collections: DBCollections, opts: Opts): VitePlugin {
|
||||
return {
|
||||
name: 'astro:db',
|
||||
enforce: 'pre',
|
||||
resolveId(id) {
|
||||
if (id === VIRTUAL_MODULE_ID) {
|
||||
return resolvedVirtualModuleId;
|
||||
}
|
||||
},
|
||||
load(id) {
|
||||
if (id !== resolvedVirtualModuleId) return;
|
||||
|
||||
if (opts.mode === 'dev') {
|
||||
return getLocalVirtualModuleContents({ collections });
|
||||
}
|
||||
|
||||
return getProdVirtualModuleContents({
|
||||
collections,
|
||||
projectId: opts.projectId,
|
||||
appToken: opts.token,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const seedErrorMessage = `${red(
|
||||
'⚠️ Failed to seed data.',
|
||||
)} Is the seed file out-of-date with recent schema changes?`;
|
||||
|
||||
export function getLocalVirtualModuleContents({ collections }: { collections: DBCollections }) {
|
||||
const seedFile = SUPPORTED_SEED_FILES.map((f) => fileURLToPath(new URL(f, ROOT))).find((f) =>
|
||||
existsSync(f),
|
||||
);
|
||||
return `
|
||||
import { collectionToTable } from ${INTERNAL_PKG_IMP};
|
||||
import { createLocalDb } from ${INTERNAL_LOCAL_PKG_IMP};
|
||||
|
||||
export const db = await createLocalDb(${JSON.stringify(collections)});
|
||||
${drizzleFilterExps}
|
||||
|
||||
${getStringifiedCollectionExports(collections)}
|
||||
|
||||
${
|
||||
seedFile
|
||||
? `try {
|
||||
await import(${JSON.stringify(seedFile)});
|
||||
} catch {
|
||||
console.error(${JSON.stringify(seedErrorMessage)});
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function getProdVirtualModuleContents({
|
||||
collections,
|
||||
projectId,
|
||||
appToken,
|
||||
}: {
|
||||
collections: DBCollections;
|
||||
projectId: string;
|
||||
appToken: string;
|
||||
}) {
|
||||
return `
|
||||
import { collectionToTable, createDb } from ${INTERNAL_PKG_IMP};
|
||||
|
||||
export const db = createDb(${JSON.stringify(projectId)}, ${JSON.stringify(appToken)});
|
||||
${drizzleFilterExps}
|
||||
|
||||
${getStringifiedCollectionExports(collections)}
|
||||
`;
|
||||
}
|
||||
|
||||
function getStringifiedCollectionExports(collections: DBCollections) {
|
||||
return Object.entries(collections)
|
||||
.map(
|
||||
([name, collection]) =>
|
||||
`export const ${name} = collectionToTable(${JSON.stringify(name)}, ${JSON.stringify(
|
||||
collection,
|
||||
)}, false)`,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
54
packages/db/src/vite-plugin-inject-env-ts.ts
Normal file
54
packages/db/src/vite-plugin-inject-env-ts.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { AstroConfig } from 'astro';
|
||||
import { bold, cyan } from 'kleur/colors';
|
||||
import { normalizePath } from 'vite';
|
||||
import { DOT_ASTRO_DIR, DB_TYPES_FILE } from './consts.js';
|
||||
import type { VitePlugin } from './utils.js';
|
||||
|
||||
export function getEnvTsPath({ srcDir }: { srcDir: URL }) {
|
||||
return new URL('env.d.ts', srcDir);
|
||||
}
|
||||
|
||||
export function vitePluginInjectEnvTs({ config }: { config: AstroConfig }): VitePlugin {
|
||||
return {
|
||||
name: 'db-inject-env-ts',
|
||||
// Use `post` to ensure project setup is complete
|
||||
// Ex. `.astro` types have been written
|
||||
enforce: 'post',
|
||||
async config() {
|
||||
await setUpEnvTs({ config });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function setUpEnvTs({ config }: { config: AstroConfig }) {
|
||||
const envTsPath = getEnvTsPath(config);
|
||||
const envTsPathRelativetoRoot = normalizePath(
|
||||
path.relative(fileURLToPath(config.root), fileURLToPath(envTsPath)),
|
||||
);
|
||||
|
||||
if (existsSync(envTsPath)) {
|
||||
let typesEnvContents = await readFile(envTsPath, 'utf-8');
|
||||
|
||||
if (!existsSync(DOT_ASTRO_DIR)) return;
|
||||
|
||||
const dbTypeReference = getDBTypeReference(config);
|
||||
|
||||
if (!typesEnvContents.includes(dbTypeReference)) {
|
||||
typesEnvContents = `${dbTypeReference}\n${typesEnvContents}`;
|
||||
await writeFile(envTsPath, typesEnvContents, 'utf-8');
|
||||
console.info(`${cyan(bold('[astro:db]'))} Added ${bold(envTsPathRelativetoRoot)} types`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDBTypeReference({ srcDir }: { srcDir: URL }) {
|
||||
const contentTypesRelativeToSrcDir = normalizePath(
|
||||
path.relative(fileURLToPath(srcDir), fileURLToPath(DB_TYPES_FILE)),
|
||||
);
|
||||
|
||||
return `/// <reference path=${JSON.stringify(contentTypesRelativeToSrcDir)} />`;
|
||||
}
|
464
packages/db/test/sync.js
Normal file
464
packages/db/test/sync.js
Normal file
|
@ -0,0 +1,464 @@
|
|||
// @ts-nocheck
|
||||
import { D1Database, D1DatabaseAPI } from '@miniflare/d1';
|
||||
import { createSQLiteDB } from '@miniflare/shared';
|
||||
import { expect } from 'chai';
|
||||
import { collectionSchema } from 'circle-rhyme-yes-measure';
|
||||
import { describe, it } from 'mocha';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
getCollectionChangeQueries,
|
||||
getCreateTableQuery,
|
||||
getMigrationQueries,
|
||||
} from '../dist/cli/sync/queries.js';
|
||||
import { field } from '../dist/config.js';
|
||||
|
||||
const COLLECTION_NAME = 'Users';
|
||||
|
||||
const userInitial = collectionSchema.parse({
|
||||
fields: {
|
||||
name: field.text(),
|
||||
age: field.number(),
|
||||
email: field.text({ unique: true }),
|
||||
mi: field.text({ optional: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const defaultPromptResponse = {
|
||||
allowDataLoss: false,
|
||||
fieldRenames: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => false,
|
||||
},
|
||||
),
|
||||
collectionRenames: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => false,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
function userChangeQueries(oldCollection, newCollection, promptResponses = defaultPromptResponse) {
|
||||
return getCollectionChangeQueries({
|
||||
collectionName: COLLECTION_NAME,
|
||||
oldCollection,
|
||||
newCollection,
|
||||
promptResponses,
|
||||
});
|
||||
}
|
||||
|
||||
function configChangeQueries(
|
||||
oldCollections,
|
||||
newCollections,
|
||||
promptResponses = defaultPromptResponse,
|
||||
) {
|
||||
return getMigrationQueries({
|
||||
oldCollections,
|
||||
newCollections,
|
||||
promptResponses,
|
||||
});
|
||||
}
|
||||
|
||||
describe('getMigrationQueries', () => {
|
||||
it('should be empty when collections are the same', async () => {
|
||||
const oldCollections = { [COLLECTION_NAME]: userInitial };
|
||||
const newCollections = { [COLLECTION_NAME]: userInitial };
|
||||
const queries = await configChangeQueries(oldCollections, newCollections);
|
||||
expect(queries).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it('should create table for new collections', async () => {
|
||||
const oldCollections = {};
|
||||
const newCollections = { [COLLECTION_NAME]: userInitial };
|
||||
const queries = await configChangeQueries(oldCollections, newCollections);
|
||||
expect(queries).to.deep.equal([getCreateTableQuery(COLLECTION_NAME, userInitial)]);
|
||||
});
|
||||
|
||||
it('should drop table for removed collections', async () => {
|
||||
const oldCollections = { [COLLECTION_NAME]: userInitial };
|
||||
const newCollections = {};
|
||||
const queries = await configChangeQueries(oldCollections, newCollections);
|
||||
expect(queries).to.deep.equal([`DROP TABLE "${COLLECTION_NAME}"`]);
|
||||
});
|
||||
|
||||
it('should rename table for renamed collections', async () => {
|
||||
const rename = 'Peeps';
|
||||
const oldCollections = { [COLLECTION_NAME]: userInitial };
|
||||
const newCollections = { [rename]: userInitial };
|
||||
const queries = await configChangeQueries(oldCollections, newCollections, {
|
||||
...defaultPromptResponse,
|
||||
collectionRenames: { [rename]: COLLECTION_NAME },
|
||||
});
|
||||
expect(queries).to.deep.equal([`ALTER TABLE "${COLLECTION_NAME}" RENAME TO "${rename}"`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCollectionChangeQueries', () => {
|
||||
it('should be empty when collections are the same', async () => {
|
||||
const queries = await userChangeQueries(userInitial, userInitial);
|
||||
expect(queries).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it('should be empty when type updated to same underlying SQL type', async () => {
|
||||
const blogInitial = collectionSchema.parse({
|
||||
fields: {
|
||||
title: field.text(),
|
||||
draft: field.boolean(),
|
||||
},
|
||||
});
|
||||
const blogFinal = collectionSchema.parse({
|
||||
fields: {
|
||||
...blogInitial.fields,
|
||||
draft: field.number(),
|
||||
},
|
||||
});
|
||||
const queries = await userChangeQueries(blogInitial, blogFinal);
|
||||
expect(queries).to.deep.equal([]);
|
||||
});
|
||||
|
||||
describe('ALTER RENAME COLUMN', () => {
|
||||
it('when renaming a field', async () => {
|
||||
const userFinal = {
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
},
|
||||
};
|
||||
userFinal.fields.middleInitial = userFinal.fields.mi;
|
||||
delete userFinal.fields.mi;
|
||||
|
||||
const queries = await userChangeQueries(userInitial, userFinal, {
|
||||
...defaultPromptResponse,
|
||||
fieldRenames: { middleInitial: 'mi' },
|
||||
});
|
||||
expect(queries).to.deep.equal([
|
||||
`ALTER TABLE "${COLLECTION_NAME}" RENAME COLUMN "mi" TO "middleInitial"`,
|
||||
]);
|
||||
await runsOnD1WithoutFailing({ queries });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lossy table recreate', () => {
|
||||
it('when changing a field type', async () => {
|
||||
const userFinal = {
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
age: field.text(),
|
||||
},
|
||||
};
|
||||
|
||||
const queries = await userChangeQueries(userInitial, userFinal, {
|
||||
...defaultPromptResponse,
|
||||
allowDataLoss: true,
|
||||
});
|
||||
expect(queries).to.have.lengthOf(3);
|
||||
|
||||
const tempTableName = getTempTableName(queries[0]);
|
||||
expect(tempTableName).to.be.a('string');
|
||||
expect(queries).to.deep.equal([
|
||||
`CREATE TABLE "${tempTableName}" ("id" text PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL, "email" text NOT NULL UNIQUE, "mi" text)`,
|
||||
'DROP TABLE "Users"',
|
||||
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
|
||||
]);
|
||||
await runsOnD1WithoutFailing({ queries, allowDataLoss: true });
|
||||
});
|
||||
|
||||
it('when changing a field to unique', async () => {
|
||||
const userFinal = {
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
age: field.text({ unique: true }),
|
||||
},
|
||||
};
|
||||
|
||||
const queries = await userChangeQueries(userInitial, userFinal, {
|
||||
...defaultPromptResponse,
|
||||
allowDataLoss: true,
|
||||
});
|
||||
expect(queries).to.have.lengthOf(3);
|
||||
|
||||
const tempTableName = getTempTableName(queries[0]);
|
||||
expect(tempTableName).to.be.a('string');
|
||||
expect(queries).to.deep.equal([
|
||||
`CREATE TABLE "${tempTableName}" ("id" text PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL UNIQUE, "email" text NOT NULL UNIQUE, "mi" text)`,
|
||||
'DROP TABLE "Users"',
|
||||
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
|
||||
]);
|
||||
await runsOnD1WithoutFailing({ queries, allowDataLoss: true });
|
||||
});
|
||||
|
||||
it('when changing a field to required without default', async () => {
|
||||
const userFinal = {
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
mi: field.text(),
|
||||
},
|
||||
};
|
||||
|
||||
const queries = await userChangeQueries(userInitial, userFinal, {
|
||||
...defaultPromptResponse,
|
||||
allowDataLoss: true,
|
||||
});
|
||||
|
||||
expect(queries).to.have.lengthOf(3);
|
||||
|
||||
const tempTableName = getTempTableName(queries[0]);
|
||||
expect(tempTableName).to.be.a('string');
|
||||
expect(queries).to.deep.equal([
|
||||
`CREATE TABLE "${tempTableName}" ("id" text PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text NOT NULL)`,
|
||||
'DROP TABLE "Users"',
|
||||
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
|
||||
]);
|
||||
await runsOnD1WithoutFailing({ queries, allowDataLoss: true });
|
||||
});
|
||||
|
||||
it('when changing a field to required with default', async () => {
|
||||
const userFinal = {
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
mi: field.text({ default: 'A' }),
|
||||
},
|
||||
};
|
||||
|
||||
const queries = await userChangeQueries(userInitial, userFinal, {
|
||||
...defaultPromptResponse,
|
||||
allowDataLoss: true,
|
||||
});
|
||||
|
||||
expect(queries).to.have.lengthOf(3);
|
||||
|
||||
const tempTableName = getTempTableName(queries[0]);
|
||||
expect(tempTableName).to.be.a('string');
|
||||
expect(queries).to.deep.equal([
|
||||
`CREATE TABLE "${tempTableName}" ("id" text PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text NOT NULL DEFAULT 'A')`,
|
||||
'DROP TABLE "Users"',
|
||||
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
|
||||
]);
|
||||
await runsOnD1WithoutFailing({ queries, allowDataLoss: true });
|
||||
});
|
||||
|
||||
it('when adding a required field without a default', async () => {
|
||||
const userFinal = {
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
phoneNumber: field.text(),
|
||||
},
|
||||
};
|
||||
|
||||
const queries = await userChangeQueries(userInitial, userFinal, {
|
||||
...defaultPromptResponse,
|
||||
allowDataLoss: true,
|
||||
});
|
||||
expect(queries).to.have.lengthOf(3);
|
||||
|
||||
const tempTableName = getTempTableName(queries[0]);
|
||||
expect(tempTableName).to.be.a('string');
|
||||
expect(queries).to.deep.equal([
|
||||
`CREATE TABLE "${tempTableName}" ("id" text PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text NOT NULL)`,
|
||||
'DROP TABLE "Users"',
|
||||
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
|
||||
]);
|
||||
await runsOnD1WithoutFailing({ queries, allowDataLoss: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lossless table recreate', () => {
|
||||
it('when adding an optional unique field', async () => {
|
||||
const userFinal = {
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
phoneNumber: field.text({ unique: true, optional: true }),
|
||||
},
|
||||
};
|
||||
|
||||
const queries = await userChangeQueries(userInitial, userFinal, {
|
||||
...defaultPromptResponse,
|
||||
allowDataLoss: true,
|
||||
});
|
||||
expect(queries).to.have.lengthOf(4);
|
||||
|
||||
const tempTableName = getTempTableName(queries[0]);
|
||||
expect(tempTableName).to.be.a('string');
|
||||
expect(queries).to.deep.equal([
|
||||
`CREATE TABLE "${tempTableName}" ("id" text PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "phoneNumber" text UNIQUE)`,
|
||||
`INSERT INTO "${tempTableName}" ("id", "name", "age", "email", "mi") SELECT "id", "name", "age", "email", "mi" FROM "Users"`,
|
||||
'DROP TABLE "Users"',
|
||||
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
|
||||
]);
|
||||
await runsOnD1WithoutFailing({ queries });
|
||||
});
|
||||
|
||||
it('when dropping unique column', async () => {
|
||||
const userFinal = {
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
},
|
||||
};
|
||||
delete userFinal.fields.email;
|
||||
|
||||
const queries = await userChangeQueries(userInitial, userFinal);
|
||||
expect(queries).to.have.lengthOf(4);
|
||||
|
||||
const tempTableName = getTempTableName(queries[0]);
|
||||
expect(tempTableName).to.be.a('string');
|
||||
expect(queries).to.deep.equal([
|
||||
`CREATE TABLE "${tempTableName}" ("id" text PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "mi" text)`,
|
||||
`INSERT INTO "${tempTableName}" ("id", "name", "age", "mi") SELECT "id", "name", "age", "mi" FROM "Users"`,
|
||||
'DROP TABLE "Users"',
|
||||
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
|
||||
]);
|
||||
await runsOnD1WithoutFailing({ queries });
|
||||
});
|
||||
|
||||
it('when updating to a runtime default', async () => {
|
||||
const initial = collectionSchema.parse({
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
age: field.date(),
|
||||
},
|
||||
});
|
||||
|
||||
const userFinal = {
|
||||
fields: {
|
||||
...initial.fields,
|
||||
age: field.date({ default: 'now' }),
|
||||
},
|
||||
};
|
||||
|
||||
const queries = await userChangeQueries(initial, userFinal);
|
||||
expect(queries).to.have.lengthOf(4);
|
||||
|
||||
const tempTableName = getTempTableName(queries[0]);
|
||||
expect(tempTableName).to.be.a('string');
|
||||
expect(queries).to.deep.equal([
|
||||
`CREATE TABLE "${tempTableName}" ("id" text PRIMARY KEY, "name" text NOT NULL, "age" text NOT NULL DEFAULT CURRENT_TIMESTAMP, "email" text NOT NULL UNIQUE, "mi" text)`,
|
||||
`INSERT INTO "${tempTableName}" ("id", "name", "age", "email", "mi") SELECT "id", "name", "age", "email", "mi" FROM "Users"`,
|
||||
'DROP TABLE "Users"',
|
||||
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
|
||||
]);
|
||||
await runsOnD1WithoutFailing({ queries });
|
||||
});
|
||||
|
||||
it('when adding a field with a runtime default', async () => {
|
||||
const userFinal = {
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
birthday: field.date({ default: 'now' }),
|
||||
},
|
||||
};
|
||||
|
||||
const queries = await userChangeQueries(userInitial, userFinal);
|
||||
expect(queries).to.have.lengthOf(4);
|
||||
|
||||
const tempTableName = getTempTableName(queries[0]);
|
||||
expect(tempTableName).to.be.a('string');
|
||||
expect(queries).to.deep.equal([
|
||||
`CREATE TABLE "${tempTableName}" ("id" text PRIMARY KEY, "name" text NOT NULL, "age" integer NOT NULL, "email" text NOT NULL UNIQUE, "mi" text, "birthday" text NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||
`INSERT INTO "${tempTableName}" ("id", "name", "age", "email", "mi") SELECT "id", "name", "age", "email", "mi" FROM "Users"`,
|
||||
'DROP TABLE "Users"',
|
||||
`ALTER TABLE "${tempTableName}" RENAME TO "Users"`,
|
||||
]);
|
||||
await runsOnD1WithoutFailing({ queries });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ALTER ADD COLUMN', () => {
|
||||
it('when adding an optional field', async () => {
|
||||
const userFinal = {
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
birthday: field.date({ optional: true }),
|
||||
},
|
||||
};
|
||||
|
||||
const queries = await userChangeQueries(userInitial, userFinal);
|
||||
expect(queries).to.deep.equal(['ALTER TABLE "Users" ADD COLUMN "birthday" text']);
|
||||
await runsOnD1WithoutFailing({ queries });
|
||||
});
|
||||
|
||||
it('when adding a required field with default', async () => {
|
||||
const defaultDate = new Date('2023-01-01');
|
||||
const userFinal = collectionSchema.parse({
|
||||
fields: {
|
||||
...userInitial.fields,
|
||||
birthday: field.date({ default: new Date('2023-01-01') }),
|
||||
},
|
||||
});
|
||||
|
||||
const queries = await userChangeQueries(userInitial, userFinal);
|
||||
expect(queries).to.deep.equal([
|
||||
`ALTER TABLE "Users" ADD COLUMN "birthday" text NOT NULL DEFAULT '${defaultDate.toISOString()}'`,
|
||||
]);
|
||||
await runsOnD1WithoutFailing({ queries });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ALTER DROP COLUMN', () => {
|
||||
it('when removing optional or required fields', async () => {
|
||||
const userFinal = {
|
||||
fields: {
|
||||
name: userInitial.fields.name,
|
||||
email: userInitial.fields.email,
|
||||
},
|
||||
};
|
||||
|
||||
const queries = await userChangeQueries(userInitial, userFinal);
|
||||
expect(queries).to.deep.equal([
|
||||
'ALTER TABLE "Users" DROP COLUMN "age"',
|
||||
'ALTER TABLE "Users" DROP COLUMN "mi"',
|
||||
]);
|
||||
await runsOnD1WithoutFailing({ queries });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** @param {string} query */
|
||||
function getTempTableName(query) {
|
||||
return query.match(/Users_([a-z0-9]+)/)?.[0];
|
||||
}
|
||||
|
||||
/** @param {{ queries: string[]; oldCollection?: typeof userInitial; allowDataLoss?: boolean }} queries */
|
||||
async function runsOnD1WithoutFailing({
|
||||
queries,
|
||||
oldCollection = userInitial,
|
||||
allowDataLoss = false,
|
||||
}) {
|
||||
const sqlite = await createSQLiteDB(':memory:');
|
||||
const d1 = new D1Database(new D1DatabaseAPI(sqlite));
|
||||
|
||||
const createTable = getCreateTableQuery(COLLECTION_NAME, oldCollection);
|
||||
const insertExampleEntries = [
|
||||
`INSERT INTO "Users" ("id", "name", "age", "email") VALUES ('1', 'John', 20, 'john@test.gov')`,
|
||||
`INSERT INTO "Users" ("id", "name", "age", "email") VALUES ('2', 'Jane', 21, 'jane@test.club')`,
|
||||
];
|
||||
await d1.batch([createTable, ...insertExampleEntries].map((q) => d1.prepare(q)));
|
||||
|
||||
try {
|
||||
await d1.batch(queries.map((q) => d1.prepare(q)));
|
||||
const userQuery = d1.prepare(`SELECT * FROM "Users"`);
|
||||
const { results } = await userQuery.all();
|
||||
expect(results).to.have.lengthOf(allowDataLoss ? 0 : insertExampleEntries.length);
|
||||
sqlite.close();
|
||||
expect(true).to.be.true;
|
||||
} catch (err) {
|
||||
expect.fail(getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
const d1ErrorValidator = z.object({
|
||||
message: z.string().refine((s) => s.startsWith('D1_')),
|
||||
cause: z.object({ message: z.string() }),
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {unknown} e
|
||||
* @returns {string}
|
||||
*/
|
||||
function getErrorMessage(e) {
|
||||
if (e instanceof Error) {
|
||||
const d1Error = d1ErrorValidator.safeParse(e);
|
||||
if (d1Error.success) return d1Error.data.cause.message;
|
||||
return e.message;
|
||||
}
|
||||
return JSON.stringify(e);
|
||||
}
|
11
packages/db/tsconfig.build.json
Normal file
11
packages/db/tsconfig.build.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
}
|
||||
}
|
4
packages/db/tsconfig.json
Normal file
4
packages/db/tsconfig.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"exclude": ["node_modules", "dist", "test", "bin"]
|
||||
}
|
Loading…
Add table
Reference in a new issue