0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-02-03 22:29:08 -05:00

add primaryKey support

This commit is contained in:
Fred K. Schott 2024-01-24 16:05:24 -08:00
parent 65696de832
commit 7c786276f5
8 changed files with 144 additions and 296 deletions

View file

@ -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

View file

@ -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) => {

View file

@ -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 ?? {}));

View file

@ -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<string[]> {
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<string, DBField>;
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<string, unknown>) {
*
* @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<BooleanField>
| WithDefaultDefined<JsonField>;
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':

View file

@ -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);
// }
// }

View file

@ -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),
// });
// }

View file

@ -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<string[]> {
@ -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<DBCollections> {
return JSON.parse(await readFile('./migrations/0000_snapshot.json', 'utf-8'));
export async function loadInitialSnapshot(): Promise<DBSnapshot> {
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<DBCollections> {
export async function initializeFromMigrations(allMigrationFiles: string[]): Promise<DBSnapshot> {
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: {} };
}

View file

@ -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<string, DBCollection>;
export type DBSnapshot = {
version: number;
meta: Record<string, never>;
schema: Record<string, DBCollection>;
};
export type ReadableDBCollection = z.infer<typeof readableCollectionSchema>;
export type WritableDBCollection = z.infer<typeof writableCollectionSchema>;