diff --git a/src/components/settings/Settings.svelte b/src/components/settings/Settings.svelte index 7641992e4..611580c6a 100644 --- a/src/components/settings/Settings.svelte +++ b/src/components/settings/Settings.svelte @@ -70,6 +70,7 @@ anonymize={false} creator={$user ? { + v: 1, email: $user.email, uid: $user.uid, name: $user.displayName ?? null, diff --git a/src/db/CreatorDatabase.ts b/src/db/CreatorDatabase.ts index 62d488c54..902087c5e 100644 --- a/src/db/CreatorDatabase.ts +++ b/src/db/CreatorDatabase.ts @@ -3,13 +3,33 @@ import type { Database } from './Database'; import { functions } from './firebase'; import type { UserIdentifier } from 'firebase-admin/auth'; +export const CreatorCollection = 'creators'; + /** The type for a record returned by our cloud functions */ -export type Creator = { +type CreatorSchemaV1 = { + /** A version of the creator record */ + v: 1; uid: string; name: string | null; email: string | null; }; +export type Creator = CreatorSchemaV1; + +type CreatorSchemaUnknownVersion = CreatorSchemaV1; + +/** Upgrades old versions of the creator schema. */ +export function upgradeCreator(creator: CreatorSchemaUnknownVersion): Creator { + switch (creator.v) { + case 1: + return creator; + default: + throw new Error( + `Unknown creator schema version ${creator.v}` + ) as never; + } +} + export default class CreatorDatabase { /** The main database that manages this gallery database */ readonly database: Database; diff --git a/src/db/Database.ts b/src/db/Database.ts index efeb1b9cb..b198468e4 100644 --- a/src/db/Database.ts +++ b/src/db/Database.ts @@ -17,7 +17,7 @@ import ProjectsDatabase from './ProjectsDatabase'; import LocalesDatabase from './LocalesDatabase'; import SettingsDatabase from './SettingsDatabase'; import GalleryDatabase from './GalleryDatabase'; -import CreatorDatabase from './CreatorDatabase'; +import CreatorDatabase, { CreatorCollection } from './CreatorDatabase'; import DefaultLocale from '../locale/DefaultLocale'; export enum SaveStatus { @@ -95,7 +95,7 @@ export class Database { if (firestore && this.user) { // Save in firestore setDoc( - doc(firestore, 'users', this.user.uid), + doc(firestore, CreatorCollection, this.user.uid), this.Settings.toObject() ); } @@ -173,7 +173,7 @@ export class Database { // Archiving was successful, delete the user's settings and then the user. try { - await deleteDoc(doc(firestore, 'users', user.uid)); + await deleteDoc(doc(firestore, CreatorCollection, user.uid)); await deleteUser(user); } catch (err) { console.error(err); diff --git a/src/db/GalleryDatabase.ts b/src/db/GalleryDatabase.ts index 5d793dfdf..6119536c0 100644 --- a/src/db/GalleryDatabase.ts +++ b/src/db/GalleryDatabase.ts @@ -11,10 +11,12 @@ import { deleteDoc, } from 'firebase/firestore'; import { v4 as uuidv4 } from 'uuid'; -import Gallery from '../models/Gallery'; +import Gallery, { + deserializeGallery, + type SerializedGallery, +} from '../models/Gallery'; import type { Database } from './Database'; import { firestore } from './firebase'; -import type { SerializedGallery } from '../models/Gallery'; import { FirebaseError } from 'firebase/app'; import { get, writable, type Writable } from 'svelte/store'; import type Project from '../models/Project'; @@ -22,6 +24,10 @@ import { toLocaleString } from '../locale/Locale'; import { getExampleGalleries } from '../examples/examples'; import type Locales from '../locale/Locales'; +/** The name of the galleries collection in Firebase */ +export const GalleriesCollection = 'galleries'; + +/** The in-memory representation of a Gallery, for type safe manipulation and analysis. */ export default class GalleryDatabase { /** The main database that manages this gallery database */ readonly database: Database; @@ -90,7 +96,7 @@ export default class GalleryDatabase { this.galleriesQueryUnsubscribe = onSnapshot( // Listen for any changes to galleries for which this user is a curator or creator. query( - collection(firestore, 'galleries'), + collection(firestore, GalleriesCollection), or( where('curators', 'array-contains', user.uid), where('creators', 'array-contains', user.uid) @@ -106,11 +112,8 @@ export default class GalleryDatabase { // Go through all of the galleries and update them. snapshot.forEach((galleryDoc) => { - // Get the gallery data from the Firestore document - const data = galleryDoc.data() as SerializedGallery; - // Wrap it in a gallery. - const gallery = new Gallery(data); + const gallery = deserializeGallery(galleryDoc.data()); // Get the store for the gallery, or make one if we don't have one yet, and update the map. // Also check the public galleries, in case we loaded it there first, so we reuse the same store. @@ -159,6 +162,7 @@ export default class GalleryDatabase { const description: Record = {}; description[toLocaleString(locales.getLocales()[0])] = ''; const gallery: SerializedGallery = { + v: 1, id, path: null, name, @@ -192,11 +196,11 @@ export default class GalleryDatabase { // Didn't find it locally? See if we get read it from the database. if (firestore) { try { - const galDoc = await getDoc(doc(firestore, 'galleries', id)); + const galDoc = await getDoc( + doc(firestore, GalleriesCollection, id) + ); if (galDoc.exists()) { - const gallery = new Gallery( - galDoc.data() as SerializedGallery - ); + const gallery = deserializeGallery(galDoc.data()); const store = this.publicGalleries.get(id) ?? writable(gallery); @@ -223,7 +227,7 @@ export default class GalleryDatabase { async edit(gallery: Gallery) { if (firestore === undefined) return undefined; await setDoc( - doc(firestore, 'galleries', gallery.getID()), + doc(firestore, GalleriesCollection, gallery.getID()), gallery.data ); @@ -237,7 +241,7 @@ export default class GalleryDatabase { async delete(gallery: Gallery) { if (firestore === undefined) return undefined; - await deleteDoc(doc(firestore, 'galleries', gallery.getID())); + await deleteDoc(doc(firestore, GalleriesCollection, gallery.getID())); // The realtime query will remove it. } diff --git a/src/db/ProjectsDatabase.ts b/src/db/ProjectsDatabase.ts index fcbc52802..d7692a9a0 100644 --- a/src/db/ProjectsDatabase.ts +++ b/src/db/ProjectsDatabase.ts @@ -1,8 +1,7 @@ import Dexie, { liveQuery, type Observable, type Table } from 'dexie'; -import type { SerializedProject } from '../models/Project'; import { PersistenceType, ProjectHistory } from './ProjectHistory'; import { writable, type Writable } from 'svelte/store'; -import Project, { ProjectSchema } from '../models/Project'; +import Project from '../models/Project'; import type { Locale } from '../locale/Locale'; import { SaveStatus, type Database } from './Database'; import { @@ -24,18 +23,31 @@ import type Node from '../nodes/Node'; import Source from '../nodes/Source'; import { ExamplePrefix, getExample } from '../examples/examples'; import { unknownFlags } from '../models/Moderation'; +import { + ProjectSchemaLatestVersion, + upgradeProject, + type SerializedProject, + type SerializedProjectUnknownVersion, + ProjectSchema, +} from '../models/ProjectSchemas'; + +/** The name of the projects collection in Firebase */ +export const ProjectsCollection = 'projects'; +/** The schema of the IndexedDB cache of projects. */ export class ProjectsDexie extends Dexie { projects!: Table; constructor() { super('wordplay'); - this.version(1).stores({ + this.version(ProjectSchemaLatestVersion).stores({ projects: '++id, name, locales, owner, collabators', }); } - async getProject(id: string): Promise { + async getProject( + id: string + ): Promise { const project = await this.projects.where('id').equals(id).toArray(); return project[0]; } @@ -137,7 +149,7 @@ export default class ProjectsDatabase { } async deserialize( - project: SerializedProject + project: SerializedProjectUnknownVersion ): Promise { return Project.deserializeProject(this.database.Locales, project); } @@ -165,7 +177,7 @@ export default class ProjectsDatabase { // and deleting any tracked locally that didn't appear in the snapshot. this.projectsQueryUnsubscribe = onSnapshot( query( - collection(firestore, 'projects'), + collection(firestore, ProjectsCollection), or( where('owner', '==', user.uid), where('collaborators', 'array-contains', user.uid) @@ -368,7 +380,9 @@ export default class ProjectsDatabase { // Not there? See if Firebase has it. if (firestore) { try { - const projectDoc = await getDoc(doc(firestore, 'projects', id)); + const projectDoc = await getDoc( + doc(firestore, ProjectsCollection, id) + ); if (projectDoc.exists()) { const project = await this.parseProject(projectDoc.data()); if (project !== undefined) @@ -411,7 +425,7 @@ export default class ProjectsDatabase { // a collection. else if (firestore && persist) { setDoc( - doc(firestore, 'projects', project.getID()), + doc(firestore, ProjectsCollection, project.getID()), project.serialize() ); } @@ -456,7 +470,7 @@ export default class ProjectsDatabase { await this.database.Galleries.removeProject(project); // Delete the project doc - await deleteDoc(doc(firestore, 'projects', id)); + await deleteDoc(doc(firestore, ProjectsCollection, id)); // Delete from the local cache. this.deleteLocalProject(id); @@ -526,7 +540,10 @@ export default class ProjectsDatabase { .serialize() ); })) - batch.set(doc(firestore, 'projects', project.id), project); + batch.set( + doc(firestore, ProjectsCollection, project.id), + project + ); await batch.commit(); // Mark all projects saved to the cloud if successful. @@ -609,7 +626,13 @@ export default class ProjectsDatabase { async parseProject(data: unknown): Promise { // If the project data doesn't parse, then return nothing, since it's not valid. try { - const project = ProjectSchema.parse(data); + // Assume it's a project of an unknown version and upgrade it. + const serialized = upgradeProject( + data as SerializedProjectUnknownVersion + ); + // Now parse it with Zod, verifying it complies with the schema. + const project = ProjectSchema.parse(serialized); + // Now convert it to an in-memory project so we can manipulate it more easily. return await this.deserialize(project); } catch (_) { return undefined; diff --git a/src/db/SettingsDatabase.ts b/src/db/SettingsDatabase.ts index f44364ade..09fc170c8 100644 --- a/src/db/SettingsDatabase.ts +++ b/src/db/SettingsDatabase.ts @@ -19,6 +19,7 @@ import { DarkSetting } from './DarkSetting'; import { doc, getDoc } from 'firebase/firestore'; import { firestore } from './firebase'; import type Setting from './Setting'; +import { CreatorCollection } from './CreatorDatabase'; /** Enscapsulates settings stored in localStorage. */ export default class SettingsDatabase { @@ -58,7 +59,9 @@ export default class SettingsDatabase { if (user === null) return; // Get the config from the database - const config = await getDoc(doc(firestore, 'users', user.uid)); + const config = await getDoc( + doc(firestore, CreatorCollection, user.uid) + ); if (config.exists()) { const data = config.data(); // Copy each key/value pair from the database to memory and the local store. diff --git a/src/examples/examples.test.ts b/src/examples/examples.test.ts index 90a7ac809..944a2324f 100644 --- a/src/examples/examples.test.ts +++ b/src/examples/examples.test.ts @@ -1,11 +1,11 @@ import { test, expect } from 'vitest'; -import type { SerializedProject } from '../models/Project'; import Project from '../models/Project'; import { Locales } from '../db/Database'; import { readdirSync, readFileSync } from 'fs'; import path from 'path'; import { parseSerializedProject } from './examples'; import { DefaultLocales } from '../locale/DefaultLocale'; +import type { SerializedProject } from '../models/ProjectSchemas'; const projects: SerializedProject[] = []; readdirSync(path.join('static', 'examples'), { withFileTypes: true }).forEach( diff --git a/src/examples/examples.ts b/src/examples/examples.ts index fa0c22adb..30280510b 100644 --- a/src/examples/examples.ts +++ b/src/examples/examples.ts @@ -1,11 +1,14 @@ -import type { SerializedProject } from '../models/Project'; import { parseNames } from '../parser/parseBind'; import { toTokens } from '../parser/toTokens'; -import Gallery from '../models/Gallery'; +import Gallery, { GallerySchemaLatestVersion } from '../models/Gallery'; import { moderatedFlags } from '../models/Moderation'; import type Locales from '../locale/Locales'; import { toLocaleString } from '../locale/Locale'; import type { GalleryText } from '../locale/GalleryTexts'; +import { + ProjectSchemaLatestVersion, + type SerializedProject, +} from '../models/ProjectSchemas'; /** This mirrors the static path to examples, but also helps distinguish project IDs from example project names. */ export const ExamplePrefix = 'example-'; @@ -38,6 +41,7 @@ export function parseSerializedProject( // Return stuff for display return { + v: ProjectSchemaLatestVersion, name, id, sources: sources, @@ -75,6 +79,7 @@ function createGallery( projects: string[] ) { return new Gallery({ + v: GallerySchemaLatestVersion, id, path: id, name: Object.fromEntries( diff --git a/src/models/Gallery.ts b/src/models/Gallery.ts index 831607a61..5642f7215 100644 --- a/src/models/Gallery.ts +++ b/src/models/Gallery.ts @@ -2,8 +2,12 @@ import type Locale from '../locale/Locale'; import { toLocaleString } from '../locale/Locale'; import type Locales from '../locale/Locales'; +export const GallerySchemaLatestVersion = 1; + /** The schema for a gallery */ -export type SerializedGallery = { +type SerializedGalleryV1 = { + /** Version of the gallery schema, so we can upgrade them. */ + v: 1; /** Unique Firestore id */ id: string; /** A vanity URL name, globally unique, must be valid URL path */ @@ -29,6 +33,27 @@ export type SerializedGallery = { featured: boolean; }; +type SerializedGalleryUnknownVersion = SerializedGalleryV1; + +export function upgradeGallery( + gallery: SerializedGalleryUnknownVersion +): SerializedGallery { + switch (gallery.v) { + case GallerySchemaLatestVersion: + return gallery; + default: + throw new Error('unknown gallery version: ' + gallery.v) as never; + } +} + +export type SerializedGallery = SerializedGalleryV1; + +export function deserializeGallery(gallery: unknown): Gallery { + return new Gallery( + upgradeGallery(gallery as SerializedGalleryUnknownVersion) + ); +} + /** * A wrapper to represent a Gallery document from the database. It helps enforce * rules and semantics about galleries client-side. @@ -36,6 +61,8 @@ export type SerializedGallery = { export default class Gallery { readonly data: SerializedGallery; constructor(data: SerializedGallery) { + data = upgradeGallery(data); + this.data = { ...data }; // Guarantee no duplicates. diff --git a/src/models/Project.ts b/src/models/Project.ts index 979318052..9952c3cdb 100644 --- a/src/models/Project.ts +++ b/src/models/Project.ts @@ -26,63 +26,16 @@ import { getBestSupportedLocales, toLocaleString } from '../locale/Locale'; import { toTokens } from '../parser/toTokens'; import type LocalesDatabase from '../db/LocalesDatabase'; import { moderatedFlags, type Moderation } from './Moderation'; -import { z } from 'zod'; import DefaultLocale from '../locale/DefaultLocale'; import Locales from '../locale/Locales'; - -const PathSchema = z.array( - z.object({ type: z.string(), index: z.number().min(0) }) -); -const CaretSchema = z.union([z.number().min(0), PathSchema]); - -type SerializedCaret = z.infer; - -const SourceSchema = z.object({ - names: z.string(), - code: z.string(), - caret: CaretSchema, -}); - -/** How we store sources as JSON in databases */ -export type SerializedSource = z.infer; - -/** Define the schema for projects */ -export const ProjectSchema = z.object({ - /** A very likely unique uuid4 string */ - id: z.string().uuid(), - /** A single Translation, serialized */ - name: z.string(), - /** The source files in the project */ - sources: z.array(SourceSchema), - /** A list of locales on which this project is dependent. All ISO 639-1 languaage codes, followed by a -, followed by ISO 3166-2 region code: https://en.wikipedia.org/wiki/ISO_3166-2 */ - locales: z.array(z.string()), - /** The Firestore user ID owner of this project */ - owner: z.nullable(z.string()), - /** A list of Firestore user IDs that have privileges to edit this project */ - collaborators: z.array(z.string().uuid()), - /** Whether this project can be viewed by anyone */ - public: z.boolean(), - /** True if the project is listed in a creator's list of projects */ - listed: z.boolean(), - /** True if the project is archived */ - archived: z.boolean(), - /** When this was lasted edited, as a unix time in milliseconds */ - timestamp: z.number(), - /** Whether this project has ever been saved to the cloud. Needed for syncing. */ - persisted: z.boolean(), - /** An optional gallery ID, indicating which gallery this project is in. */ - gallery: z.nullable(z.string().uuid()), - /** Moderation state */ - flags: z.object({ - dehumanization: z.nullable(z.boolean()), - violence: z.nullable(z.boolean()), - disclosure: z.nullable(z.boolean()), - misinformation: z.nullable(z.boolean()), - }), -}); - -/** How we store projects as JSON in databases */ -export type SerializedProject = z.infer; +import { + ProjectSchemaLatestVersion, + type SerializedCaret, + type SerializedProject, + type SerializedProjectUnknownVersion, + type SerializedSource, + upgradeProject, +} from './ProjectSchemas'; /** * How we store projects in memory, mirroring the data in the deserialized form. @@ -192,6 +145,7 @@ export default class Project { timestamp: number | undefined = undefined ) { return new Project({ + v: ProjectSchemaLatestVersion, id: id ?? uuidv4(), name, main, @@ -806,8 +760,11 @@ export default class Project { static async deserializeProject( localesDB: LocalesDatabase, - project: SerializedProject + project: SerializedProjectUnknownVersion ): Promise { + // Upgrade the project just in case. + project = upgradeProject(project); + const sources = project.sources.map((source) => Project.deserializeSource(source) ); @@ -822,6 +779,7 @@ export default class Project { ); return new Project({ + v: ProjectSchemaLatestVersion, id: project.id, name: project.name, main: sources[0], @@ -898,6 +856,7 @@ export default class Project { serialize(): SerializedProject { return { + v: ProjectSchemaLatestVersion, id: this.getID(), name: this.getName(), sources: this.getSources().map((source) => { diff --git a/src/models/ProjectSchemas.ts b/src/models/ProjectSchemas.ts new file mode 100644 index 000000000..1218b8790 --- /dev/null +++ b/src/models/ProjectSchemas.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; + +const PathSchema = z.array( + z.object({ type: z.string(), index: z.number().min(0) }) +); +const CaretSchema = z.union([z.number().min(0), PathSchema]); + +export type SerializedCaret = z.infer; + +const SourceSchema = z.object({ + names: z.string(), + code: z.string(), + caret: CaretSchema, +}); + +/** How we store sources as JSON in databases */ +export type SerializedSource = z.infer; + +export const ProjectSchemaLatestVersion = 1; + +/** Define the schema for projects */ +export const ProjectSchemaV1 = z.object({ + /** The version of the project schema, used for keeping track of different versions of the project schema. */ + v: z.literal(ProjectSchemaLatestVersion), + /** A very likely unique uuid4 string */ + id: z.string().uuid(), + /** A single Translation, serialized */ + name: z.string(), + /** The source files in the project */ + sources: z.array(SourceSchema), + /** A list of locales on which this project is dependent. All ISO 639-1 languaage codes, followed by a -, followed by ISO 3166-2 region code: https://en.wikipedia.org/wiki/ISO_3166-2 */ + locales: z.array(z.string()), + /** The Firestore user ID owner of this project */ + owner: z.nullable(z.string()), + /** A list of Firestore user IDs that have privileges to edit this project */ + collaborators: z.array(z.string().uuid()), + /** Whether this project can be viewed by anyone */ + public: z.boolean(), + /** True if the project is listed in a creator's list of projects */ + listed: z.boolean(), + /** True if the project is archived */ + archived: z.boolean(), + /** When this was lasted edited, as a unix time in milliseconds */ + timestamp: z.number(), + /** Whether this project has ever been saved to the cloud. Needed for syncing. */ + persisted: z.boolean(), + /** An optional gallery ID, indicating which gallery this project is in. */ + gallery: z.nullable(z.string().uuid()), + /** Moderation state */ + flags: z.object({ + dehumanization: z.nullable(z.boolean()), + violence: z.nullable(z.boolean()), + disclosure: z.nullable(z.boolean()), + misinformation: z.nullable(z.boolean()), + }), +}); + +export const ProjectSchema = ProjectSchemaV1; + +type SerializedProjectV1 = z.infer; + +/** How we store projects as JSON in databases. These could be one of many versions, but currently there's only one. */ +export type SerializedProject = SerializedProjectV1; + +export type SerializedProjectUnknownVersion = SerializedProjectV1; + +/** Project updgrader */ +export function upgradeProject( + project: SerializedProjectUnknownVersion +): SerializedProject { + if (project.v === undefined) project.v = 1; + + switch (project.v) { + case ProjectSchemaLatestVersion: + return project; + default: + throw new Error('Unexpected project version ' + project.v) as never; + } +} diff --git a/src/routes/galleries/+page.svelte b/src/routes/galleries/+page.svelte index 74a3594ab..9e10bd03b 100644 --- a/src/routes/galleries/+page.svelte +++ b/src/routes/galleries/+page.svelte @@ -17,10 +17,11 @@ } from 'firebase/firestore'; import { firestore } from '../../db/firebase'; import type { SerializedGallery } from '../../models/Gallery'; - import Gallery from '../../models/Gallery'; + import Gallery, { upgradeGallery } from '../../models/Gallery'; import GalleryPreview from '../../components/app/GalleryPreview.svelte'; import Spinning from '../../components/app/Spinning.svelte'; import Button from '../../components/widgets/Button.svelte'; + import { GalleriesCollection } from '../../db/GalleryDatabase'; let lastBatch: QueryDocumentSnapshot; @@ -37,7 +38,7 @@ if (firestore === undefined) return firestore; const first = lastBatch ? query( - collection(firestore, 'galleries'), + collection(firestore, GalleriesCollection), where('public', '==', true), orderBy('featured'), orderBy('id'), @@ -45,7 +46,7 @@ limit(5) ) : query( - collection(firestore, 'galleries'), + collection(firestore, GalleriesCollection), where('public', '==', true), orderBy('featured'), orderBy('id'), @@ -60,7 +61,10 @@ loadedGalleries = [ ...(loadedGalleries ?? []), ...documentSnapshots.docs.map( - (snap) => new Gallery(snap.data() as SerializedGallery) + (snap) => + new Gallery( + upgradeGallery(snap.data() as SerializedGallery) + ) ), ]; } diff --git a/src/routes/moderate/+page.svelte b/src/routes/moderate/+page.svelte index 2c267eefe..d84bc245c 100644 --- a/src/routes/moderate/+page.svelte +++ b/src/routes/moderate/+page.svelte @@ -37,6 +37,7 @@ import Button from '../../components/widgets/Button.svelte'; import type { Flag, Moderation } from '../../models/Moderation'; import Spinning from '../../components/app/Spinning.svelte'; + import { ProjectsCollection } from '../../db/ProjectsDatabase'; const user = getUser(); @@ -70,7 +71,7 @@ return firestore; } const unmoderated = query( - collection(firestore, 'projects'), + collection(firestore, ProjectsCollection), // Construct a query for each flag to find any project that has a null flag. and( where('public', '==', true),