Skip to content

Commit

Permalink
Version schemas for upgradeability.
Browse files Browse the repository at this point in the history
  • Loading branch information
amyjko committed Oct 27, 2023
1 parent 1f98a90 commit 048dedf
Show file tree
Hide file tree
Showing 13 changed files with 220 additions and 94 deletions.
1 change: 1 addition & 0 deletions src/components/settings/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
anonymize={false}
creator={$user
? {
v: 1,
email: $user.email,
uid: $user.uid,
name: $user.displayName ?? null,
Expand Down
22 changes: 21 additions & 1 deletion src/db/CreatorDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/db/Database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
);
}
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 17 additions & 13 deletions src/db/GalleryDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,23 @@ 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';
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;
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -159,6 +162,7 @@ export default class GalleryDatabase {
const description: Record<string, string> = {};
description[toLocaleString(locales.getLocales()[0])] = '';
const gallery: SerializedGallery = {
v: 1,
id,
path: null,
name,
Expand Down Expand Up @@ -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>(gallery);
Expand All @@ -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
);

Expand All @@ -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.
}
Expand Down
45 changes: 34 additions & 11 deletions src/db/ProjectsDatabase.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<SerializedProject>;

constructor() {
super('wordplay');
this.version(1).stores({
this.version(ProjectSchemaLatestVersion).stores({
projects: '++id, name, locales, owner, collabators',
});
}

async getProject(id: string): Promise<SerializedProject | undefined> {
async getProject(
id: string
): Promise<SerializedProjectUnknownVersion | undefined> {
const project = await this.projects.where('id').equals(id).toArray();
return project[0];
}
Expand Down Expand Up @@ -137,7 +149,7 @@ export default class ProjectsDatabase {
}

async deserialize(
project: SerializedProject
project: SerializedProjectUnknownVersion
): Promise<Project | undefined> {
return Project.deserializeProject(this.database.Locales, project);
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -609,7 +626,13 @@ export default class ProjectsDatabase {
async parseProject(data: unknown): Promise<Project | undefined> {
// 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;
Expand Down
5 changes: 4 additions & 1 deletion src/db/SettingsDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/examples/examples.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
9 changes: 7 additions & 2 deletions src/examples/examples.ts
Original file line number Diff line number Diff line change
@@ -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-';
Expand Down Expand Up @@ -38,6 +41,7 @@ export function parseSerializedProject(

// Return stuff for display
return {
v: ProjectSchemaLatestVersion,
name,
id,
sources: sources,
Expand Down Expand Up @@ -75,6 +79,7 @@ function createGallery(
projects: string[]
) {
return new Gallery({
v: GallerySchemaLatestVersion,
id,
path: id,
name: Object.fromEntries(
Expand Down
29 changes: 28 additions & 1 deletion src/models/Gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -29,13 +33,36 @@ 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.
*/
export default class Gallery {
readonly data: SerializedGallery;
constructor(data: SerializedGallery) {
data = upgradeGallery(data);

this.data = { ...data };

// Guarantee no duplicates.
Expand Down
Loading

0 comments on commit 048dedf

Please sign in to comment.