summaryrefslogtreecommitdiff
path: root/apps/web/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services')
-rw-r--r--apps/web/src/services/storage/migrations/AGENTS.md10
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts139
-rw-r--r--apps/web/src/services/storage/migrations/index.ts4
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts138
-rw-r--r--apps/web/src/services/storage/migrations/v29-to-v30.ts14
5 files changed, 304 insertions, 1 deletions
diff --git a/apps/web/src/services/storage/migrations/AGENTS.md b/apps/web/src/services/storage/migrations/AGENTS.md
new file mode 100644
index 00000000..5aed00a1
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/AGENTS.md
@@ -0,0 +1,10 @@
+# Agents.md
+
+## Migration Data Policy
+
+Migrations are additive only.
+
+- Do not delete, rename, or replace persisted data in a migration.
+- When a new storage shape is needed, add the new fields alongside the old fields.
+- Runtime code may prefer or ignore old fields, but the migration must preserve them for future down migrations.
+- If old data should ever be removed, that is a separate explicit cleanup task, not part of a version migration. \ No newline at end of file
diff --git a/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts b/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts
new file mode 100644
index 00000000..c68f8dce
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV29ToV30 } from "../transformers/v29-to-v30";
+import { asRecord, asRecordArray } from "./helpers";
+
+describe("V29 to V30 Migration", () => {
+ test("flattens animation bindings and channels by path", () => {
+ const result = transformProjectV29ToV30({
+ project: {
+ id: "project-v29-animations",
+ version: 29,
+ scenes: [
+ {
+ id: "scene-1",
+ tracks: {
+ main: {
+ id: "main",
+ type: "video",
+ elements: [
+ {
+ id: "text-1",
+ type: "text",
+ animations: {
+ bindings: {
+ opacity: {
+ path: "opacity",
+ kind: "number",
+ components: [
+ { key: "value", channelId: "opacity:value" },
+ ],
+ },
+ color: {
+ path: "color",
+ kind: "color",
+ colorSpace: "srgb-linear",
+ components: [
+ { key: "r", channelId: "color:r" },
+ { key: "g", channelId: "color:g" },
+ { key: "b", channelId: "color:b" },
+ { key: "a", channelId: "color:a" },
+ ],
+ },
+ "params.label": {
+ path: "params.label",
+ kind: "discrete",
+ components: [
+ {
+ key: "value",
+ channelId: "params.label:value",
+ },
+ ],
+ },
+ },
+ channels: {
+ "opacity:value": {
+ kind: "scalar",
+ keys: [{ id: "o1", time: 0, value: 0.5 }],
+ },
+ "color:r": {
+ kind: "scalar",
+ keys: [{ id: "c1", time: 0, value: 1 }],
+ },
+ "color:g": {
+ kind: "scalar",
+ keys: [{ id: "c1", time: 0, value: 0.5 }],
+ },
+ "color:b": {
+ kind: "scalar",
+ keys: [{ id: "c1", time: 0, value: 0 }],
+ },
+ "color:a": {
+ kind: "scalar",
+ keys: [{ id: "c1", time: 0, value: 1 }],
+ },
+ "params.label:value": {
+ kind: "discrete",
+ keys: [{ id: "d1", time: 0, value: "Title" }],
+ },
+ },
+ },
+ },
+ ],
+ },
+ overlay: [],
+ audio: [],
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(30);
+ const scene = asRecordArray(result.project.scenes)[0];
+ const tracks = asRecord(scene.tracks);
+ const main = asRecord(tracks.main);
+ const element = asRecordArray(main.elements)[0];
+ const animations = asRecord(element.animations);
+ expect(animations.bindings).toBeDefined();
+ expect(animations.channels).toBeDefined();
+ expect(animations).toMatchObject({
+ opacity: {
+ keys: [{ id: "o1", time: 0, value: 0.5 }],
+ },
+ color: {
+ r: { keys: [{ id: "c1", time: 0, value: 1 }] },
+ g: { keys: [{ id: "c1", time: 0, value: 0.5 }] },
+ b: { keys: [{ id: "c1", time: 0, value: 0 }] },
+ a: { keys: [{ id: "c1", time: 0, value: 1 }] },
+ },
+ "params.label": {
+ keys: [{ id: "d1", time: 0, value: "Title" }],
+ },
+ });
+ });
+
+ test("leaves elements without animations unchanged", () => {
+ const result = transformProjectV29ToV30({
+ project: {
+ id: "project-v29-no-animations",
+ version: 29,
+ scenes: [
+ {
+ tracks: {
+ main: {
+ elements: [{ id: "image-1", type: "image" }],
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ const scene = asRecordArray(result.project.scenes)[0];
+ const tracks = asRecord(scene.tracks);
+ const main = asRecord(tracks.main);
+ const element = asRecordArray(main.elements)[0];
+ expect(element).toEqual({ id: "image-1", type: "image" });
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index 2e2c83bc..03b5ed0c 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -28,10 +28,11 @@ import { V25toV26Migration } from "./v25-to-v26";
import { V26toV27Migration } from "./v26-to-v27";
import { V27toV28Migration } from "./v27-to-v28";
import { V28toV29Migration } from "./v28-to-v29";
+import { V29toV30Migration } from "./v29-to-v30";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 29;
+export const CURRENT_PROJECT_VERSION = 30;
export const migrations = [
new V0toV1Migration(),
@@ -63,4 +64,5 @@ export const migrations = [
new V26toV27Migration(),
new V27toV28Migration(),
new V28toV29Migration(),
+ new V29toV30Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts b/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts
new file mode 100644
index 00000000..f3050742
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts
@@ -0,0 +1,138 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV29ToV30({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ const version = project.version;
+ if (typeof version !== "number") {
+ return { project, skipped: true, reason: "invalid version" };
+ }
+ if (version >= 30) {
+ return { project, skipped: true, reason: "already v30" };
+ }
+ if (version !== 29) {
+ return { project, skipped: true, reason: "not v29" };
+ }
+
+ return {
+ project: {
+ ...migrateProject({ project }),
+ version: 30,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord {
+ const nextProject = { ...project };
+ if (Array.isArray(project.scenes)) {
+ nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
+ }
+ return nextProject;
+}
+
+function migrateScene({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const nextScene = { ...scene };
+ if (isRecord(scene.tracks)) {
+ nextScene.tracks = migrateTracks({ tracks: scene.tracks });
+ }
+ return nextScene;
+}
+
+function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord {
+ const nextTracks = { ...tracks };
+ if (isRecord(tracks.main)) {
+ nextTracks.main = migrateTrack({ track: tracks.main });
+ }
+ if (Array.isArray(tracks.overlay)) {
+ nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
+ }
+ if (Array.isArray(tracks.audio)) {
+ nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
+ }
+ return nextTracks;
+}
+
+function migrateTrack({ track }: { track: unknown }): unknown {
+ if (!isRecord(track) || !Array.isArray(track.elements)) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.map((element) => migrateElement({ element })),
+ };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element) || !isRecord(element.animations)) {
+ return element;
+ }
+
+ return {
+ ...element,
+ animations: migrateAnimations({ animations: element.animations }),
+ };
+}
+
+function migrateAnimations({
+ animations,
+}: {
+ animations: ProjectRecord;
+}): ProjectRecord {
+ if (!isRecord(animations.bindings) || !isRecord(animations.channels)) {
+ return animations;
+ }
+
+ const channels = animations.channels;
+ const nextAnimations: ProjectRecord = { ...animations };
+ for (const binding of Object.values(animations.bindings)) {
+ if (!isRecord(binding) || typeof binding.path !== "string") {
+ continue;
+ }
+ if (!Array.isArray(binding.components)) {
+ continue;
+ }
+
+ const componentEntries = binding.components.flatMap((component) => {
+ if (
+ !isRecord(component) ||
+ typeof component.key !== "string" ||
+ typeof component.channelId !== "string"
+ ) {
+ return [];
+ }
+ const channel = channels[component.channelId];
+ if (!isRecord(channel)) {
+ return [];
+ }
+ return [[component.key, migrateChannel({ channel })] as const];
+ });
+ if (componentEntries.length === 0) {
+ continue;
+ }
+
+ nextAnimations[binding.path] =
+ componentEntries.length === 1 && componentEntries[0]?.[0] === "value"
+ ? componentEntries[0][1]
+ : Object.fromEntries(componentEntries);
+ }
+
+ return nextAnimations;
+}
+
+function migrateChannel({ channel }: { channel: ProjectRecord }): ProjectRecord {
+ const { kind: _kind, ...nextChannel } = channel;
+ return nextChannel;
+}
diff --git a/apps/web/src/services/storage/migrations/v29-to-v30.ts b/apps/web/src/services/storage/migrations/v29-to-v30.ts
new file mode 100644
index 00000000..0b9c23b9
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v29-to-v30.ts
@@ -0,0 +1,14 @@
+import { StorageMigration, type StorageMigrationRunArgs } from "./base";
+import type { MigrationResult, ProjectRecord } from "./transformers/types";
+import { transformProjectV29ToV30 } from "./transformers/v29-to-v30";
+
+export class V29toV30Migration extends StorageMigration {
+ from = 29;
+ to = 30;
+
+ async run({
+ project,
+ }: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> {
+ return transformProjectV29ToV30({ project });
+ }
+}