From 7c786276f5e151ec7b88bae3dcc2b6e3364ba45e Mon Sep 17 00:00:00 2001 From: "Fred K. Schott" Date: Wed, 24 Jan 2024 16:05:24 -0800 Subject: [PATCH] add primaryKey support --- packages/db/src/cli/commands/push/index.ts | 5 +- packages/db/src/cli/commands/sync/index.ts | 7 +- packages/db/src/cli/commands/verify/index.ts | 2 +- packages/db/src/cli/queries.ts | 177 ++++++++++++------- packages/db/src/cli/sync/index.ts | 112 ------------ packages/db/src/cli/sync/migrate.ts | 105 ----------- packages/db/src/migrations.ts | 25 ++- packages/db/src/types.ts | 7 + 8 files changed, 144 insertions(+), 296 deletions(-) delete mode 100644 packages/db/src/cli/sync/index.ts delete mode 100644 packages/db/src/cli/sync/migrate.ts diff --git a/packages/db/src/cli/commands/push/index.ts b/packages/db/src/cli/commands/push/index.ts index 8e6fa18910..c045a6b838 100644 --- a/packages/db/src/cli/commands/push/index.ts +++ b/packages/db/src/cli/commands/push/index.ts @@ -7,6 +7,7 @@ import type { Arguments } from 'yargs-parser'; import { appTokenError } from '../../../errors.js'; import { collectionToTable, createLocalDatabaseClient } from '../../../internal.js'; import { + createEmptySnapshot, getMigrations, initializeFromMigrations, loadInitialSnapshot, @@ -98,8 +99,8 @@ async function pushSchema({ // create a migration for the initial snapshot, if needed const initialMigrationBatch = initialSnapshot ? await getMigrationQueries({ - oldCollections: {}, - newCollections: await loadInitialSnapshot(), + oldSnapshot: createEmptySnapshot(), + newSnapshot: await loadInitialSnapshot(), }) : []; // combine all missing migrations into a single batch diff --git a/packages/db/src/cli/commands/sync/index.ts b/packages/db/src/cli/commands/sync/index.ts index 8aaab6d4a2..d6e892e71e 100644 --- a/packages/db/src/cli/commands/sync/index.ts +++ b/packages/db/src/cli/commands/sync/index.ts @@ -3,6 +3,7 @@ import deepDiff from 'deep-diff'; import { writeFile } from 'fs/promises'; import type { Arguments } from 'yargs-parser'; import { + createCurrentSnapshot, getMigrations, initializeFromMigrations, initializeMigrationsDirectory, @@ -11,7 +12,7 @@ import { getMigrationQueries } from '../../queries.js'; const { diff } = deepDiff; export async function cmd({ config }: { config: AstroConfig; flags: Arguments }) { - const currentSnapshot = JSON.parse(JSON.stringify(config.db?.collections ?? {})); + const currentSnapshot = createCurrentSnapshot(config); const allMigrationFiles = await getMigrations(); if (allMigrationFiles.length === 0) { await initializeMigrationsDirectory(currentSnapshot); @@ -27,8 +28,8 @@ export async function cmd({ config }: { config: AstroConfig; flags: Arguments }) } const migrationQueries = await getMigrationQueries({ - oldCollections: prevSnapshot, - newCollections: currentSnapshot, + oldSnapshot: prevSnapshot, + newSnapshot: currentSnapshot, }); const largestNumber = allMigrationFiles.reduce((acc, curr) => { diff --git a/packages/db/src/cli/commands/verify/index.ts b/packages/db/src/cli/commands/verify/index.ts index 30595f9440..b7f1f39a95 100644 --- a/packages/db/src/cli/commands/verify/index.ts +++ b/packages/db/src/cli/commands/verify/index.ts @@ -2,7 +2,7 @@ import type { AstroConfig } from 'astro'; import deepDiff from 'deep-diff'; import type { Arguments } from 'yargs-parser'; import { getMigrations, initializeFromMigrations } from '../../../migrations.js'; -const { diff, applyChange } = deepDiff; +const { diff } = deepDiff; export async function cmd({ config }: { config: AstroConfig; flags: Arguments }) { const currentSnapshot = JSON.parse(JSON.stringify(config.db?.collections ?? {})); diff --git a/packages/db/src/cli/queries.ts b/packages/db/src/cli/queries.ts index 2634b15c4f..0141cc1160 100644 --- a/packages/db/src/cli/queries.ts +++ b/packages/db/src/cli/queries.ts @@ -5,6 +5,7 @@ import type { DBCollections, DBField, DBFields, + DBSnapshot, DateField, FieldType, JsonField, @@ -25,18 +26,17 @@ interface PromptResponses { } export async function getMigrationQueries({ - oldCollections, - newCollections, + oldSnapshot, + newSnapshot, promptResponses, }: { - oldCollections: DBCollections; - newCollections: DBCollections; + oldSnapshot: DBSnapshot; + newSnapshot: DBSnapshot; promptResponses?: PromptResponses; -}) { +}): Promise { const queries: string[] = []; - let added = getAddedCollections(oldCollections, newCollections); - let dropped = getDroppedCollections(oldCollections, newCollections); - + let added = getAddedCollections(oldSnapshot, newSnapshot); + let dropped = getDroppedCollections(oldSnapshot, newSnapshot); if (!isEmpty(added) && !isEmpty(dropped)) { const resolved = await resolveCollectionRenames( added, @@ -62,8 +62,8 @@ export async function getMigrationQueries({ queries.push(dropQuery); } - for (const [collectionName, newCollection] of Object.entries(newCollections)) { - const oldCollection = oldCollections[collectionName]; + for (const [collectionName, newCollection] of Object.entries(newSnapshot.schema)) { + const oldCollection = oldSnapshot.schema[collectionName]; if (!oldCollection) continue; const collectionChangeQueries = await getCollectionChangeQueries({ collectionName, @@ -130,12 +130,9 @@ export async function getCollectionChangeQueries({ )} field ${color.blue(color.bold(fieldName))}. ${color.red( 'This will delete all existing data in the collection!' )} We recommend setting a default value to avoid data loss.`, - 'updated-required': `Changing ${color.blue(color.bold(collectionName))} field ${color.blue( + 'added-unique': `Adding unique ${color.blue(color.bold(collectionName))} field ${color.blue( color.bold(fieldName) - )} to required. ${color.red('This will delete all existing data in the collection!')}`, - 'updated-unique': `Changing ${color.blue(color.bold(collectionName))} field ${color.blue( - color.bold(fieldName) - )} to unique. ${color.red('This will delete all existing data in the collection!')}`, + )}. ${color.red('This will delete all existing data in the collection!')}`, 'updated-type': `Changing the type of ${color.blue( color.bold(collectionName) )} field ${color.blue(color.bold(fieldName))}. ${color.red( @@ -159,11 +156,22 @@ export async function getCollectionChangeQueries({ } } + const addedPrimaryKey = Object.entries(added).find( + ([, field]) => hasPrimaryKey(field) + ); + const droppedPrimaryKey = Object.entries(dropped).find( + ([, field]) => hasPrimaryKey(field) + ); + const updatedPrimaryKey = Object.entries(updated).find( + ([, field]) => + (hasPrimaryKey(field.old) || hasPrimaryKey(field.new)) + ); const recreateTableQueries = getRecreateTableQueries({ - unescCollectionName: collectionName, + unescapedCollectionName: collectionName, newCollection, added, hasDataLoss: dataLossCheck.dataLoss, + migrateHiddenPrimaryKey: !addedPrimaryKey && !droppedPrimaryKey && !updatedPrimaryKey, }); queries.push(...recreateTableQueries); return queries; @@ -288,25 +296,31 @@ async function resolveCollectionRenames( return { added, dropped, renamed }; } -function getAddedCollections(oldCollections: DBCollections, newCollections: DBCollections) { +function getAddedCollections( + oldCollections: DBSnapshot, + newCollections: DBSnapshot +): DBCollections { const added: DBCollections = {}; - for (const [key, newCollection] of Object.entries(newCollections)) { - if (!(key in oldCollections)) added[key] = newCollection; + for (const [key, newCollection] of Object.entries(newCollections.schema)) { + if (!(key in oldCollections.schema)) added[key] = newCollection; } return added; } -function getDroppedCollections(oldCollections: DBCollections, newCollections: DBCollections) { +function getDroppedCollections( + oldCollections: DBSnapshot, + newCollections: DBSnapshot +): DBCollections { const dropped: DBCollections = {}; - for (const [key, oldCollection] of Object.entries(oldCollections)) { - if (!(key in newCollections)) dropped[key] = oldCollection; + for (const [key, oldCollection] of Object.entries(oldCollections.schema)) { + if (!(key in newCollections.schema)) dropped[key] = oldCollection; } return dropped; } -function getFieldRenameQueries(unescCollectionName: string, renamed: Renamed): string[] { +function getFieldRenameQueries(unescapedCollectionName: string, renamed: Renamed): string[] { const queries: string[] = []; - const collectionName = sqlite.escapeName(unescCollectionName); + const collectionName = sqlite.escapeName(unescapedCollectionName); for (const { from, to } of renamed) { const q = `ALTER TABLE ${collectionName} RENAME COLUMN ${sqlite.escapeName( @@ -323,12 +337,12 @@ function getFieldRenameQueries(unescCollectionName: string, renamed: Renamed): s * `canUseAlterTableAddColumn` and `canAlterTableDropColumn` checks! */ function getAlterTableQueries( - unescCollectionName: string, + unescapedCollectionName: string, added: DBFields, dropped: DBFields ): string[] { const queries: string[] = []; - const collectionName = sqlite.escapeName(unescCollectionName); + const collectionName = sqlite.escapeName(unescapedCollectionName); for (const [unescFieldName, field] of Object.entries(added)) { const fieldName = sqlite.escapeName(unescFieldName); @@ -350,40 +364,52 @@ function getAlterTableQueries( } function getRecreateTableQueries({ - unescCollectionName, + unescapedCollectionName, newCollection, added, hasDataLoss, + migrateHiddenPrimaryKey, }: { - unescCollectionName: string; + unescapedCollectionName: string; newCollection: DBCollection; added: Record; hasDataLoss: boolean; + migrateHiddenPrimaryKey: boolean; }): string[] { - const unescTempName = `${unescCollectionName}_${genTempTableName()}`; + const unescTempName = `${unescapedCollectionName}_${genTempTableName()}`; const tempName = sqlite.escapeName(unescTempName); - const collectionName = sqlite.escapeName(unescCollectionName); - const queries = [getCreateTableQuery(unescTempName, newCollection)]; + const collectionName = sqlite.escapeName(unescapedCollectionName); - if (!hasDataLoss) { - const newColumns = ['id', ...Object.keys(newCollection.fields)]; - const originalColumns = newColumns.filter((i) => !(i in added)); - const escapedColumns = originalColumns.map((c) => sqlite.escapeName(c)).join(', '); - - queries.push( - `INSERT INTO ${tempName} (${escapedColumns}) SELECT ${escapedColumns} FROM ${collectionName}` - ); + if (hasDataLoss) { + return [`DROP TABLE ${collectionName}`, getCreateTableQuery(collectionName, newCollection)]; } + const newColumns = [...Object.keys(newCollection.fields)]; + if (migrateHiddenPrimaryKey) { + newColumns.unshift('_id'); + } + const escapedColumns = newColumns + .filter((i) => !(i in added)) + .map((c) => sqlite.escapeName(c)) + .join(', '); - queries.push(`DROP TABLE ${collectionName}`); - queries.push(`ALTER TABLE ${tempName} RENAME TO ${collectionName}`); - return queries; + return [ + getCreateTableQuery(unescTempName, newCollection), + `INSERT INTO ${tempName} (${escapedColumns}) SELECT ${escapedColumns} FROM ${collectionName}`, + `DROP TABLE ${collectionName}`, + `ALTER TABLE ${tempName} RENAME TO ${collectionName}`, + ]; } export function getCreateTableQuery(collectionName: string, collection: DBCollection) { let query = `CREATE TABLE ${sqlite.escapeName(collectionName)} (`; - const colQueries = ['"id" text PRIMARY KEY']; + const colQueries = []; + const colHasPrimaryKey = Object.entries(collection.fields).find( + ([, field]) => hasPrimaryKey(field) + ); + if (!colHasPrimaryKey) { + colQueries.push('_id INTEGER PRIMARY KEY AUTOINCREMENT'); + } for (const [columnName, column] of Object.entries(collection.fields)) { const colQuery = `${sqlite.escapeName(columnName)} ${schemaTypeToSqlType( column.type @@ -395,16 +421,19 @@ export function getCreateTableQuery(collectionName: string, collection: DBCollec return query; } -function getModifiers(columnName: string, column: DBField) { +function getModifiers(fieldName: string, field: DBField) { let modifiers = ''; - if (!column.optional) { + if (hasPrimaryKey(field)) { + modifiers += ' PRIMARY KEY'; + } + if (!field.optional) { modifiers += ' NOT NULL'; } - if (column.unique) { + if (field.unique) { modifiers += ' UNIQUE'; } - if (hasDefault(column)) { - modifiers += ` DEFAULT ${getDefaultValueSql(columnName, column)}`; + if (hasDefault(field)) { + modifiers += ` DEFAULT ${getDefaultValueSql(fieldName, field)}`; } return modifiers; } @@ -431,19 +460,24 @@ function isEmpty(obj: Record) { * * @see https://www.sqlite.org/lang_altertable.html#alter_table_add_column */ -function canAlterTableAddColumn(column: DBField) { - if (column.unique) return false; - if (hasRuntimeDefault(column)) return false; - if (!column.optional && !hasDefault(column)) return false; +function canAlterTableAddColumn(field: DBField) { + if (field.unique) return false; + if (hasRuntimeDefault(field)) return false; + if (!field.optional && !hasDefault(field)) return false; + if (hasPrimaryKey(field)) return false; return true; } -function canAlterTableDropColumn(column: DBField) { - if (column.unique) return false; +function canAlterTableDropColumn(field: DBField) { + if (field.unique) return false; + if (hasPrimaryKey(field)) return false; return true; } -type DataLossReason = 'added-required' | 'updated-required' | 'updated-unique' | 'updated-type'; +type DataLossReason = + | 'added-required' + | 'added-unique' + | 'updated-type'; type DataLossResponse = | { dataLoss: false } | { dataLoss: true; fieldName: string; reason: DataLossReason }; @@ -453,19 +487,20 @@ function canRecreateTableWithoutDataLoss( updated: UpdatedFields ): DataLossResponse { for (const [fieldName, a] of Object.entries(added)) { - if (!a.optional && !hasDefault(a)) + if (hasPrimaryKey(a) && a.type !== 'number') { return { dataLoss: true, fieldName, reason: 'added-required' }; + } + if (!a.optional && !hasDefault(a)) { + return { dataLoss: true, fieldName, reason: 'added-required' }; + } + if (a.unique) { + return { dataLoss: true, fieldName, reason: 'added-unique' }; + } } for (const [fieldName, u] of Object.entries(updated)) { - // Cannot respect default value when changing to NOT NULL - // TODO: use UPDATE query to set default where IS NULL - // @see https://dataschool.com/learn-sql/how-to-replace-nulls-with-0s/ - if (u.old.optional && !u.new.optional) - return { dataLoss: true, fieldName, reason: 'updated-required' }; - if (!u.old.unique && u.new.unique) - return { dataLoss: true, fieldName, reason: 'updated-unique' }; - if (u.old.type !== u.new.type && !canChangeTypeWithoutQuery(u.old, u.new)) + if (u.old.type !== u.new.type && !canChangeTypeWithoutQuery(u.old, u.new)) { return { dataLoss: true, fieldName, reason: 'updated-type' }; + } } return { dataLoss: false }; } @@ -535,9 +570,19 @@ type DBFieldWithDefault = | WithDefaultDefined | WithDefaultDefined; +function hasPrimaryKey(field: DBField) { + return 'primaryKey' in field && !!field.primaryKey; +} + // Type narrowing the default fails on union types, so use a type guard function hasDefault(field: DBField): field is DBFieldWithDefault { - return field.default !== undefined; + if (field.default !== undefined) { + return true; + } + if (hasPrimaryKey(field) && field.type === 'number') { + return true; + } + return false; } function hasRuntimeDefault(field: DBField): field is DBFieldWithDefault { @@ -549,7 +594,7 @@ function getDefaultValueSql(columnName: string, column: DBFieldWithDefault): str case 'boolean': return column.default ? 'TRUE' : 'FALSE'; case 'number': - return `${column.default}`; + return `${column.default || 'AUTOINCREMENT'}`; case 'text': return sqlite.escapeString(column.default); case 'date': diff --git a/packages/db/src/cli/sync/index.ts b/packages/db/src/cli/sync/index.ts deleted file mode 100644 index 40fe1e1a27..0000000000 --- a/packages/db/src/cli/sync/index.ts +++ /dev/null @@ -1,112 +0,0 @@ -// import type { DBCollections } from '../../types.js'; -// import yargs from 'yargs-parser'; -// import { appTokenError } from '../../errors.js'; -// import { -// getAstroStudioEnv, -// getStudioUrl, -// getRemoteDatabaseUrl, -// isAppTokenValid, -// } from '../../utils.js'; -// import { migrate } from './migrate.js'; -// import { collectionToTable, createLocalDatabaseClient } from '../../internal.js'; -// import type { Query } from 'drizzle-orm'; -// import type { InArgs, InStatement } from '@libsql/client'; - -// export async function sync({ collections }: { collections: DBCollections }) { -// const args = yargs(process.argv.slice(3), { -// string: ['dry-run', 'seed'], -// }); -// const isDryRun = 'dry-run' in args; -// const shouldSeed = 'seed' in args; -// const remoteDbUrl = getRemoteDatabaseUrl(); - -// const appToken = getAstroStudioEnv().ASTRO_STUDIO_APP_TOKEN; -// if (!appToken || !(await isAppTokenValid({ remoteDbUrl, appToken }))) { -// // eslint-disable-next-line no-console -// console.error(appTokenError); -// process.exit(1); -// } - -// try { -// await setSyncStatus({ status: 'RUNNING', remoteDbUrl, appToken }); -// await migrate({ collections, isDryRun, appToken }); -// await setSyncStatus({ status: 'SUCCESS', remoteDbUrl, appToken }); -// if (shouldSeed) { -// await tempDataPush({ collections, appToken, isDryRun }); -// } -// // eslint-disable-next-line no-console -// console.info('Sync complete 🔄'); -// } catch (e) { -// await setSyncStatus({ status: 'FAILED', remoteDbUrl, appToken }); -// // eslint-disable-next-line no-console -// console.error(e); -// return; -// } -// } - -// /** TODO: refine with migration changes */ -// async function tempDataPush({ -// collections, -// appToken, -// isDryRun, -// }: { -// collections: DBCollections; -// appToken: string; -// isDryRun?: boolean; -// }) { -// const db = await createLocalDatabaseClient({ collections, dbUrl: ':memory:', seeding: true }); -// const queries: Query[] = []; - -// for (const [name, collection] of Object.entries(collections)) { -// if (collection.writable || !collection.data) continue; -// const table = collectionToTable(name, collection); -// const insert = db.insert(table).values(await collection.data()); - -// queries.push(insert.toSQL()); -// } -// const url = new URL('/db/query', getRemoteDatabaseUrl()); -// const requestBody: InStatement[] = queries.map((q) => ({ -// sql: q.sql, -// args: q.params as InArgs, -// })); - -// if (isDryRun) { -// console.info('[DRY RUN] Batch data seed:', JSON.stringify(requestBody, null, 2)); -// return new Response(null, { status: 200 }); -// } - -// return await fetch(url, { -// method: 'POST', -// headers: new Headers({ -// Authorization: `Bearer ${appToken}`, -// }), -// body: JSON.stringify(requestBody), -// }); -// } - -// async function setSyncStatus({ -// remoteDbUrl, -// appToken, -// status, -// }: { -// remoteDbUrl: string; -// appToken: string; -// status: 'RUNNING' | 'FAILED' | 'SUCCESS'; -// }) { -// const syncStatusUrl = new URL('/api/rest/sync-status', getStudioUrl()); -// syncStatusUrl.searchParams.set('workerUrl', remoteDbUrl); -// syncStatusUrl.searchParams.set('status', status); - -// const response = await fetch(syncStatusUrl, { -// method: 'PATCH', -// headers: { -// Authorization: `Bearer ${appToken}`, -// }, -// }); - -// if (!response.ok) { -// // eslint-disable-next-line no-console -// console.error('Unexpected problem completing sync.'); -// process.exit(1); -// } -// } diff --git a/packages/db/src/cli/sync/migrate.ts b/packages/db/src/cli/sync/migrate.ts deleted file mode 100644 index 3728c6caf1..0000000000 --- a/packages/db/src/cli/sync/migrate.ts +++ /dev/null @@ -1,105 +0,0 @@ -// import type { InArgs, InStatement } from '@libsql/client'; -// import { -// STUDIO_ADMIN_TABLE_ROW_ID, -// adminTable, -// } from './admin.js'; -// import { type SQL, eq, sql } from 'drizzle-orm'; -// import { SQLiteAsyncDialect } from 'drizzle-orm/sqlite-core'; -// import { red } from 'kleur/colors'; -// import { collectionsSchema, type DBCollections } from '../../types.js'; -// import { getRemoteDatabaseUrl } from '../../utils.js'; -// import { authenticationError, unexpectedAstroAdminError } from '../../errors.js'; -// import { createLocalDatabaseClient } from './remote-db.js'; -// import { getMigrationQueries } from './queries.js'; - -// const sqliteDialect = new SQLiteAsyncDialect(); - -// export async function migrate({ -// collections: newCollections, -// appToken, -// isDryRun, -// }: { -// collections: DBCollections; -// appToken: string; -// isDryRun?: boolean; -// }) { -// const db = createLocalDatabaseClient(appToken); -// const adminEntry = await db -// .select({ collections: adminTable.collections }) -// .from(adminTable) -// .get(); - -// if (!adminEntry) { -// // eslint-disable-next-line no-console -// console.error( -// `${red( -// '⚠️ Unexpected error syncing collections.', -// )} You may need to initialize a new project with \`studio init\`.`, -// ); -// process.exit(1); -// } - -// if (JSON.stringify(newCollections) === adminEntry.collections) { -// // eslint-disable-next-line no-console -// console.info('Collections already up to date'); -// return; -// } - -// const oldCollections = collectionsSchema.parse( -// adminEntry.collections ? JSON.parse(adminEntry.collections) : {}, -// ); -// const queries: SQL[] = []; -// const migrationQueries = await getMigrationQueries({ oldCollections, newCollections }); -// queries.push(...migrationQueries.map((q) => sql.raw(q))); - -// const updateCollectionsJson = db -// .update(adminTable) -// .set({ collections: JSON.stringify(newCollections) }) -// .where(eq(adminTable.id, STUDIO_ADMIN_TABLE_ROW_ID)) -// .getSQL(); -// queries.push(updateCollectionsJson); - -// const res = await runBatchQuery({ queries, appToken, isDryRun }); -// if (!res.ok) { -// if (res.status === 401) { -// // eslint-disable-next-line no-console -// console.error(authenticationError); -// process.exit(1); -// } - -// // eslint-disable-next-line no-console -// console.error(unexpectedAstroAdminError); -// process.exit(1); -// } -// } - -// async function runBatchQuery({ -// queries: sqlQueries, -// appToken, -// isDryRun, -// }: { -// queries: SQL[]; -// appToken: string; -// isDryRun?: boolean; -// }) { -// const queries = sqlQueries.map((q) => sqliteDialect.sqlToQuery(q)); -// const requestBody: InStatement[] = queries.map((q) => ({ -// sql: q.sql, -// args: q.params as InArgs, -// })); - -// if (isDryRun) { -// console.info('[DRY RUN] Batch query:', JSON.stringify(requestBody, null, 2)); -// return new Response(null, { status: 200 }); -// } - -// const url = new URL('/db/query', getRemoteDatabaseUrl()); - -// return await fetch(url, { -// method: 'POST', -// headers: new Headers({ -// Authorization: `Bearer ${appToken}`, -// }), -// body: JSON.stringify(requestBody), -// }); -// } diff --git a/packages/db/src/migrations.ts b/packages/db/src/migrations.ts index eae11a334b..26aa4bb3ae 100644 --- a/packages/db/src/migrations.ts +++ b/packages/db/src/migrations.ts @@ -1,6 +1,7 @@ import deepDiff from 'deep-diff'; import { mkdir, readFile, readdir, writeFile } from 'fs/promises'; -import type { DBCollections } from './types.js'; +import type { DBSnapshot } from './types.js'; +import type { AstroConfig } from 'astro'; const { applyChange } = deepDiff; export async function getMigrations(): Promise { @@ -17,18 +18,20 @@ export async function loadMigration(migration: string): Promise<{ diff: any[]; d return JSON.parse(await readFile(`./migrations/${migration}`, 'utf-8')); } -export async function loadInitialSnapshot(): Promise { - return JSON.parse(await readFile('./migrations/0000_snapshot.json', 'utf-8')); +export async function loadInitialSnapshot(): Promise { + const snapshot = JSON.parse(await readFile('./migrations/0000_snapshot.json', 'utf-8')); + if (!snapshot.version) { + return { version: 2, meta: {}, schema: snapshot }; + } + return snapshot; } -export async function initializeMigrationsDirectory(currentSnapshot: unknown) { +export async function initializeMigrationsDirectory(currentSnapshot: DBSnapshot) { await mkdir('./migrations', { recursive: true }); await writeFile('./migrations/0000_snapshot.json', JSON.stringify(currentSnapshot, undefined, 2)); } -export async function initializeFromMigrations( - allMigrationFiles: string[] -): Promise { +export async function initializeFromMigrations(allMigrationFiles: string[]): Promise { const prevSnapshot = await loadInitialSnapshot(); for (const migration of allMigrationFiles) { if (migration === '0000_snapshot.json') continue; @@ -39,3 +42,11 @@ export async function initializeFromMigrations( } return prevSnapshot; } + +export function createCurrentSnapshot(config: AstroConfig): DBSnapshot { + const schema = JSON.parse(JSON.stringify(config.db?.collections ?? {})); + return { version: 2, meta: {}, schema }; +} +export function createEmptySnapshot(): DBSnapshot { + return { version: 2, meta: {}, schema: {} }; +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 157b3a9595..08ff59aac7 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -16,12 +16,14 @@ const booleanFieldSchema = baseFieldSchema.extend({ const numberFieldSchema = baseFieldSchema.extend({ type: z.literal('number'), default: z.number().optional(), + primaryKey: z.boolean().optional(), }); const textFieldSchema = baseFieldSchema.extend({ type: z.literal('text'), multiline: z.boolean().optional(), default: z.string().optional(), + primaryKey: z.boolean().optional(), }); const dateFieldSchema = baseFieldSchema.extend({ @@ -95,6 +97,11 @@ export type DBCollection = z.infer< typeof readableCollectionSchema | typeof writableCollectionSchema >; export type DBCollections = Record; +export type DBSnapshot = { + version: number; + meta: Record; + schema: Record; +}; export type ReadableDBCollection = z.infer; export type WritableDBCollection = z.infer;