From 5472b341a42ac6e64d2eade4af8e61eb46c1cb0c Mon Sep 17 00:00:00 2001 From: Chris Drackett Date: Wed, 8 Apr 2026 08:20:31 -0700 Subject: [PATCH 1/4] add versions to server --- apps/server/drizzle.config.ts | 14 +- .../mysql/0001_automation_versioning.sql | 48 +++ .../postgresql/0001_automation_versioning.sql | 48 +++ .../sqlite/0001_automation_versioning.sql | 19 + .../migrations/sqlite/meta/_journal.json | 7 + apps/server/package.json | 6 +- apps/server/src/app/app.module.mts | 4 + .../automation-version.controller.mts | 84 +++++ apps/server/src/app/controllers/index.mts | 1 + .../services/automation-version.service.mts | 281 ++++++++++++++ apps/server/src/database/database.module.mts | 2 + apps/server/src/database/schemas/common.mts | 28 +- apps/server/src/database/schemas/mysql.mts | 27 +- apps/server/src/database/schemas/postgres.mts | 27 +- apps/server/src/database/schemas/sqlite.mts | 27 +- .../services/automation-version.service.mts | 348 ++++++++++++++++++ .../database/services/automation.service.mts | 18 +- apps/server/src/database/services/index.mts | 1 + .../server/src/utils/contracts/automation.mts | 35 +- 19 files changed, 1000 insertions(+), 25 deletions(-) create mode 100644 apps/server/migrations/mysql/0001_automation_versioning.sql create mode 100644 apps/server/migrations/postgresql/0001_automation_versioning.sql create mode 100644 apps/server/migrations/sqlite/0001_automation_versioning.sql create mode 100644 apps/server/src/app/controllers/automation-version.controller.mts create mode 100644 apps/server/src/app/services/automation-version.service.mts create mode 100644 apps/server/src/database/services/automation-version.service.mts diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts index c289ddf..fa9d10c 100644 --- a/apps/server/drizzle.config.ts +++ b/apps/server/drizzle.config.ts @@ -1,3 +1,4 @@ +import { mkdirSync } from "fs"; import { defineConfig } from "drizzle-kit"; // Get database type from environment or default to sqlite @@ -10,6 +11,17 @@ const baseConfig = { strict: true, }; +// Resolve the SQLite database URL and ensure its directory exists +// Match the default used by @digital-alchemy/synapse: file:/synapse_storage.db +const sqliteUrl = process.env.DATABASE_URL || "file:./synapse_storage.db"; +if (databaseType === "sqlite") { + // Strip the "file:" prefix to get the filesystem path, then ensure the directory exists + const filePath = sqliteUrl.replace(/^file:/, ""); + const lastSlash = filePath.lastIndexOf("/"); + const dirPath = lastSlash > 0 ? filePath.slice(0, lastSlash) : "."; + mkdirSync(dirPath, { recursive: true }); +} + // Database-specific configurations const configs = { sqlite: { @@ -17,7 +29,7 @@ const configs = { dialect: "sqlite" as const, out: "./migrations/sqlite", dbCredentials: { - url: process.env.DATABASE_URL || "file:/data/synapse_storage.db", + url: sqliteUrl, }, }, postgresql: { diff --git a/apps/server/migrations/mysql/0001_automation_versioning.sql b/apps/server/migrations/mysql/0001_automation_versioning.sql new file mode 100644 index 0000000..6529986 --- /dev/null +++ b/apps/server/migrations/mysql/0001_automation_versioning.sql @@ -0,0 +1,48 @@ +-- Add active_version_id to stored_automation +ALTER TABLE `stored_automation` ADD `active_version_id` varchar(36); +--> statement-breakpoint +ALTER TABLE `stored_automation` DROP COLUMN `draft`; +--> statement-breakpoint +ALTER TABLE `stored_automation` DROP COLUMN `version`; +--> statement-breakpoint +CREATE TABLE `automation_versions` ( + `activated_from_version_id` varchar(36), + `automation_id` varchar(36) NOT NULL, + `body` text NOT NULL, + `date` timestamp NOT NULL, + `documentation` text, + `has_code_change` varchar(10) NOT NULL DEFAULT 'true', + `has_notes_change` varchar(10) NOT NULL DEFAULT 'false', + `id` varchar(36) NOT NULL, + `is_active` varchar(10) NOT NULL DEFAULT 'false', + `is_draft` varchar(10) NOT NULL DEFAULT 'false', + `name` varchar(255), + `notes` text, + `parent_version_id` varchar(36), + `was_auto_saved` varchar(10) NOT NULL DEFAULT 'false', + `written_by_ai` varchar(10) NOT NULL DEFAULT 'false', + CONSTRAINT `automation_versions_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +-- Seed initial versions from existing automations +INSERT INTO `automation_versions` ( + `id`, `automation_id`, `body`, `date`, `documentation`, + `has_code_change`, `has_notes_change`, + `is_active`, `is_draft`, `name`, + `was_auto_saved`, `written_by_ai` +) +SELECT + UUID(), + `id`, + `body`, + `create_date`, + `documentation`, + 'true', 'false', + 'true', 'false', 'Initial version', + 'false', 'false' +FROM `stored_automation`; +--> statement-breakpoint +-- Point each automation at its initial version +UPDATE `stored_automation` sa +JOIN `automation_versions` av ON av.`automation_id` = sa.`id` +SET sa.`active_version_id` = av.`id`; diff --git a/apps/server/migrations/postgresql/0001_automation_versioning.sql b/apps/server/migrations/postgresql/0001_automation_versioning.sql new file mode 100644 index 0000000..b0198a9 --- /dev/null +++ b/apps/server/migrations/postgresql/0001_automation_versioning.sql @@ -0,0 +1,48 @@ +-- Add active_version_id to stored_automation +ALTER TABLE "stored_automation" ADD "active_version_id" text; +--> statement-breakpoint +ALTER TABLE "stored_automation" DROP COLUMN "draft"; +--> statement-breakpoint +ALTER TABLE "stored_automation" DROP COLUMN "version"; +--> statement-breakpoint +CREATE TABLE "automation_versions" ( + "activated_from_version_id" text, + "automation_id" text NOT NULL, + "body" text NOT NULL, + "date" timestamp NOT NULL, + "documentation" text, + "has_code_change" text NOT NULL DEFAULT 'true', + "has_notes_change" text NOT NULL DEFAULT 'false', + "id" text PRIMARY KEY NOT NULL, + "is_active" text NOT NULL DEFAULT 'false', + "is_draft" text NOT NULL DEFAULT 'false', + "name" text, + "notes" text, + "parent_version_id" text, + "was_auto_saved" text NOT NULL DEFAULT 'false', + "written_by_ai" text NOT NULL DEFAULT 'false' +); +--> statement-breakpoint +-- Seed initial versions from existing automations +INSERT INTO "automation_versions" ( + "id", "automation_id", "body", "date", "documentation", + "has_code_change", "has_notes_change", + "is_active", "is_draft", "name", + "was_auto_saved", "written_by_ai" +) +SELECT + gen_random_uuid()::text, + "id", + "body", + "create_date", + "documentation", + 'true', 'false', + 'true', 'false', 'Initial version', + 'false', 'false' +FROM "stored_automation"; +--> statement-breakpoint +-- Point each automation at its initial version +UPDATE "stored_automation" sa +SET "active_version_id" = av."id" +FROM "automation_versions" av +WHERE av."automation_id" = sa."id"; diff --git a/apps/server/migrations/sqlite/0001_automation_versioning.sql b/apps/server/migrations/sqlite/0001_automation_versioning.sql new file mode 100644 index 0000000..671d90c --- /dev/null +++ b/apps/server/migrations/sqlite/0001_automation_versioning.sql @@ -0,0 +1,19 @@ +ALTER TABLE `stored_automation` ADD `active_version_id` text; +--> statement-breakpoint +CREATE TABLE `automation_versions` ( + `activated_from_version_id` text, + `automation_id` text NOT NULL, + `body` text NOT NULL, + `date` text NOT NULL, + `documentation` text, + `has_code_change` text NOT NULL DEFAULT 'true', + `has_notes_change` text NOT NULL DEFAULT 'false', + `id` text PRIMARY KEY NOT NULL, + `is_active` text NOT NULL DEFAULT 'false', + `is_draft` text NOT NULL DEFAULT 'false', + `name` text, + `notes` text, + `parent_version_id` text, + `was_auto_saved` text NOT NULL DEFAULT 'false', + `written_by_ai` text NOT NULL DEFAULT 'false' +); diff --git a/apps/server/migrations/sqlite/meta/_journal.json b/apps/server/migrations/sqlite/meta/_journal.json index eb2d142..180b04b 100644 --- a/apps/server/migrations/sqlite/meta/_journal.json +++ b/apps/server/migrations/sqlite/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1765403410570, "tag": "0000_quick_stark_industries", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1774300000000, + "tag": "0001_automation_versioning", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index e120b37..23e150a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -3,9 +3,9 @@ "name": "@code-glue/server", "description": "Server component", "scripts": { - "start": "SERVE_STATIC=true tsx src/app/environments/local/main.mts", - "start:inspect": "SERVE_STATIC=true tsx --inspect src/app/environments/local/main.mts", - "start:brk": "SERVE_STATIC=true tsx --inspect-brk src/app/environments/local/main.mts", + "start": "drizzle-kit migrate && SERVE_STATIC=true tsx src/app/environments/local/main.mts", + "start:inspect": "drizzle-kit migrate && SERVE_STATIC=true tsx --inspect src/app/environments/local/main.mts", + "start:brk": "drizzle-kit migrate && SERVE_STATIC=true tsx --inspect-brk src/app/environments/local/main.mts", "build": "tsc -p tsconfig.build.json", "test": "./scripts/test.sh", "lint": "eslint src", diff --git a/apps/server/src/app/app.module.mts b/apps/server/src/app/app.module.mts index d4c108f..53b22a5 100644 --- a/apps/server/src/app/app.module.mts +++ b/apps/server/src/app/app.module.mts @@ -12,11 +12,13 @@ import { LIB_MODULE_PATCHER } from "../patch/patch.module.mts"; import { AppController, AutomationController, + AutomationVersionController, SynapseEntitiesController, TypesController, VariablesController, } from "./controllers/index.mts"; import { AutomationLogic } from "./services/automation.service.mts"; +import { AutomationVersionLogic } from "./services/automation-version.service.mts"; import { HeaderBlockService } from "./services/header-block.service.mts"; import { CodeGlueLogger } from "./services/logger.service.mts"; import { StatsService } from "./services/stats.service.mts"; @@ -54,10 +56,12 @@ export const CODE_GLUE_APP = CreateApplication({ services: { AppController, AutomationController, + AutomationVersionController, SynapseEntitiesController, TypesController, VariablesController, automation: AutomationLogic, + automationVersion: AutomationVersionLogic, header: HeaderBlockService, logger: CodeGlueLogger, stats: StatsService, diff --git a/apps/server/src/app/controllers/automation-version.controller.mts b/apps/server/src/app/controllers/automation-version.controller.mts new file mode 100644 index 0000000..907b963 --- /dev/null +++ b/apps/server/src/app/controllers/automation-version.controller.mts @@ -0,0 +1,84 @@ +import { TServiceParams } from "@digital-alchemy/core"; +import { Type } from "@sinclair/typebox"; + +const params = Type.Object({ id: Type.String() }); +const versionParams = Type.Object({ id: Type.String(), versionId: Type.String() }); + +const CreateDraftBody = Type.Object({ + body: Type.String(), + parentVersionId: Type.Optional(Type.String()), +}); + +const UpdateDraftBody = Type.Object({ + body: Type.Optional(Type.String()), + documentation: Type.Optional(Type.String()), + name: Type.Optional(Type.String()), + notes: Type.Optional(Type.String()), +}); + +const FinalizeBody = Type.Object({ + name: Type.Optional(Type.String()), + notes: Type.Optional(Type.String()), + wasAutoSaved: Type.Boolean(), + makeActive: Type.Optional(Type.Boolean()), +}); + +export function AutomationVersionController({ + http: { controller }, + config, + code_glue, +}: TServiceParams) { + controller([config.code_glue.V1, "/automation/:id/versions"], app => + app + // GET /api/v1/automation/:id/versions — list all versions for an automation + .get( + "/", + { schema: { params } }, + ({ params: { id } }) => code_glue.automationVersion.listForAutomation(id), + ) + // POST /api/v1/automation/:id/versions — create a draft version + .post( + "/", + { schema: { body: CreateDraftBody, params } }, + ({ body, params: { id } }) => + code_glue.automationVersion.createDraft(id, body.body, body.parentVersionId), + ) + // PUT /api/v1/automation/:id/versions/:versionId — update draft body or finalize + .put( + "/:versionId", + { schema: { body: UpdateDraftBody, params: versionParams } }, + ({ body, params: { versionId } }) => + code_glue.automationVersion.updateDraft(versionId, { + body: body.body, + name: body.name, + notes: body.notes, + }), + ) + // POST /api/v1/automation/:id/versions/:versionId/finalize — finalize a draft + .post( + "/:versionId/finalize", + { schema: { body: FinalizeBody, params: versionParams } }, + ({ body, params: { versionId } }) => + code_glue.automationVersion.finalizeVersion(versionId, { + name: body.name, + notes: body.notes, + wasAutoSaved: body.wasAutoSaved, + makeActive: body.makeActive, + }), + ) + // POST /api/v1/automation/:id/versions/:versionId/activate — activate a version + .post( + "/:versionId/activate", + { schema: { params: versionParams } }, + ({ params: { id, versionId } }) => + code_glue.automationVersion.activateVersion(id, versionId), + ) + // DELETE /api/v1/automation/:id/versions/:versionId — delete a version + .delete( + "/:versionId", + { schema: { params: versionParams } }, + ({ params: { versionId } }) => + code_glue.automationVersion.removeVersion(versionId), + ), + ); +} diff --git a/apps/server/src/app/controllers/index.mts b/apps/server/src/app/controllers/index.mts index 2988008..c7a0993 100644 --- a/apps/server/src/app/controllers/index.mts +++ b/apps/server/src/app/controllers/index.mts @@ -1,4 +1,5 @@ export * from "./app.controller.mts"; +export * from "./automation-version.controller.mts"; export * from "./automation.controller.mts"; export * from "./entities.controller.mts"; export * from "./types.controller.mts"; diff --git a/apps/server/src/app/services/automation-version.service.mts b/apps/server/src/app/services/automation-version.service.mts new file mode 100644 index 0000000..bb2ff2d --- /dev/null +++ b/apps/server/src/app/services/automation-version.service.mts @@ -0,0 +1,281 @@ +import { TServiceParams } from "@digital-alchemy/core"; + +import { AutomationVersionUpdateOptions, StoredAutomation } from "../../utils/index.mts"; +import { formatAutomationContext } from "../../utils/helpers/format.mts"; + +export function AutomationVersionLogic({ + database, + coordinator, + internal, + lifecycle, + logger, +}: TServiceParams) { + /** + * Returns a logger scoped to the automation's own context so entries appear + * in the per-automation Logs tab. + */ + function automationLogger(automation: StoredAutomation) { + const context = + (automation.context && `automation/${automation.context}`) || + (automation.title && formatAutomationContext(automation.title)) || + `automation/${automation.id}`; + return internal.boilerplate.logger.context(context); + } + /** + * On bootstrap: ensure every automation has at least one version. + * Handles automations that existed before the versioning migration, + * or any edge-case where a version was not created. + */ + lifecycle.onBootstrap(async function seedMissingInitialVersions() { + const automations = database.automation.list(); + const seeded: string[] = []; + + for (const automation of automations) { + const existing = database.automationVersion.listForAutomation(automation.id); + if (existing.length > 0) continue; + + const date = + (automation as unknown as { create_date?: string }).create_date ?? + automation.createDate ?? + new Date().toISOString(); + + const formattedDate = new Date(date).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + + const version = await database.automationVersion.create({ + activatedFromVersionId: undefined, + automationId: automation.id, + body: automation.body, + date, + documentation: automation.documentation, + hasCodeChange: true, + hasNotesChange: false, + isActive: true, + isDraft: false, + name: `Initial version — ${formattedDate}`, + notes: undefined, + parentVersionId: undefined, + wasAutoSaved: false, + writtenByAi: false, + }); + + // Point the automation at its new initial version + await database.automation.update(automation.id, { + activeVersionId: version.id, + } as never); + + seeded.push(automation.id); + } + + if (seeded.length > 0) { + logger.info( + { seeded: seeded.length }, + "Seeded initial versions for automations missing one", + ); + } + }); + + /** + * Create a new draft version for an automation. + * Called when the user first edits after the active version is saved. + */ + async function createDraft( + automationId: string, + body: string, + parentVersionId: string | undefined, + ) { + const automation = database.automation.get(automationId); + if (!automation) { + throw new Error(`Automation ${automationId} not found`); + } + + return await database.automationVersion.create({ + activatedFromVersionId: undefined, + automationId, + body, + date: new Date().toISOString(), + documentation: automation.documentation, + hasCodeChange: true, + hasNotesChange: false, + isActive: false, + isDraft: true, + name: undefined, + notes: undefined, + parentVersionId, + wasAutoSaved: false, + writtenByAi: false, + }); + } + + /** + * Update fields on an existing draft version. + * Body changes (debounced editor saves) also update the timestamp. + * Metadata-only changes (name, notes) leave the timestamp unchanged. + */ + async function updateDraft( + versionId: string, + opts: { body?: string; name?: string; notes?: string }, + ) { + const updates: AutomationVersionUpdateOptions = {}; + if (opts.body !== undefined) { + updates.body = opts.body; + updates.date = new Date().toISOString(); + } + if (opts.name !== undefined) updates.name = opts.name; + if (opts.notes !== undefined) updates.notes = opts.notes; + return await database.automationVersion.update(versionId, updates); + } + + /** + * Finalize a draft version — commits it to history (isDraft → false). + * + * By default (`makeActive: true`) the version also becomes the active running + * version, the coordinator reloads, and automation.body is updated. + * + * Pass `makeActive: false` to archive the draft into history without changing + * what is currently running — used when "Use as draft" needs to preserve an + * existing draft before replacing it. + */ + async function finalizeVersion( + versionId: string, + opts: { + name?: string; + notes?: string; + wasAutoSaved: boolean; + makeActive?: boolean; + }, + ) { + const makeActive = opts.makeActive !== false; // default true + + const version = database.automationVersion.get(versionId); + if (!version) { + throw new Error(`Version ${versionId} not found`); + } + + const automation = database.automation.get(version.automationId); + if (!automation) { + throw new Error(`Automation ${version.automationId} not found`); + } + + // Snapshot current documentation at finalize time + const hasNotesChange = version.documentation !== automation.documentation; + + const updated = await database.automationVersion.update(versionId, { + documentation: automation.documentation, + hasNotesChange, + isActive: makeActive, + isDraft: false, + name: opts.name, + notes: opts.notes, + wasAutoSaved: opts.wasAutoSaved, + }); + + if (makeActive) { + // Mark previous active version as inactive + const previousActive = database.automationVersion + .listForAutomation(version.automationId) + .find((v) => v.isActive && v.id !== versionId); + + if (previousActive) { + await database.automationVersion.update(previousActive.id, { isActive: false }); + } + + // Update automation body and active_version_id, then reload coordinator + await database.automation.update(version.automationId, { + activeVersionId: versionId, + body: version.body, + } as never); + + coordinator.loader.reload(version.automationId); + + const versionLabel = opts.name ? `"${opts.name}"` : "unnamed version"; + automationLogger(automation).info( + { versionId }, + `Version saved and activated: ${versionLabel}`, + ); + } + + return updated; + } + + /** + * Activate a historical version. + * Moves the active flag directly to the target version — no new version is created. + * The coordinator reloads with that version's body. Any in-progress draft is left + * untouched so the user can continue editing and save it later. + */ + async function activateVersion(automationId: string, versionId: string) { + const targetVersion = database.automationVersion.get(versionId); + if (!targetVersion) { + throw new Error(`Version ${versionId} not found`); + } + + const automation = database.automation.get(automationId); + if (!automation) { + throw new Error(`Automation ${automationId} not found`); + } + + // Mark current active version as inactive (skip drafts — they are never active) + const currentActive = database.automationVersion + .listForAutomation(automationId) + .find((v) => v.isActive); + + if (currentActive) { + await database.automationVersion.update(currentActive.id, { isActive: false }); + } + + // Move the active flag to the target version + const updatedVersion = await database.automationVersion.update(versionId, { + isActive: true, + }); + + // Update automation body and active_version_id, then reload coordinator + await database.automation.update(automationId, { + activeVersionId: versionId, + body: targetVersion.body, + } as never); + + const versionLabel = (v: { name?: string; date: string }) => + v.name ? `"${v.name}"` : new Date(v.date).toLocaleString(); + const previousLabel = currentActive ? versionLabel(currentActive) : "unknown"; + const targetLabel = versionLabel(targetVersion); + automationLogger(automation).info( + { versionId, previousVersionId: currentActive?.id }, + `Active version switched to ${targetLabel} (was: ${previousLabel})`, + ); + + coordinator.loader.reload(automationId); + + return updatedVersion; + } + + /** + * Get all versions for an automation. + */ + function listForAutomation(automationId: string) { + return database.automationVersion.listForAutomation(automationId); + } + + /** + * Delete a specific version. + */ + async function removeVersion(versionId: string) { + const version = database.automationVersion.get(versionId); + if (version?.isActive) { + throw new Error("Cannot delete the currently active version"); + } + await database.automationVersion.remove(versionId); + } + + return { + activateVersion, + createDraft, + finalizeVersion, + listForAutomation, + removeVersion, + updateDraft, + }; +} diff --git a/apps/server/src/database/database.module.mts b/apps/server/src/database/database.module.mts index 91c9efe..0ef0c58 100644 --- a/apps/server/src/database/database.module.mts +++ b/apps/server/src/database/database.module.mts @@ -3,6 +3,7 @@ import { CreateLibrary } from "@digital-alchemy/core"; import { LIB_METRICS } from "../metrics/index.mts"; import { AutomationTable, + AutomationVersionTable, DatabaseInternalsService, SynapseEntitiesTable, TypesTable, @@ -15,6 +16,7 @@ export const LIB_DATABASE = CreateLibrary({ name: "database", services: { automation: AutomationTable, + automationVersion: AutomationVersionTable, entity: SynapseEntitiesTable, // internal: DatabaseInternalsService, types: TypesTable, diff --git a/apps/server/src/database/schemas/common.mts b/apps/server/src/database/schemas/common.mts index 29476e0..656dd0d 100644 --- a/apps/server/src/database/schemas/common.mts +++ b/apps/server/src/database/schemas/common.mts @@ -18,16 +18,15 @@ export type SharedVariableRow = Omit & { export interface StoredAutomationCreateOptions { active: string; + active_version_id?: string; area?: string; body: string; context: string; - draft?: string; icon?: string; labels: string[]; parent?: string; title: string; documentation: string; - version: string; } export interface StoredAutomation extends StoredAutomationCreateOptions { @@ -40,6 +39,31 @@ export type StoredAutomationRow = Omit & { labels: string; // Stored as pipe-separated string in database }; +export interface AutomationVersionCreateOptions { + activated_from_version_id?: string; + automation_id: string; + body: string; + date: string; + documentation?: string; + has_code_change: string; + has_notes_change: string; + is_active: string; + is_draft: string; + name?: string; + notes?: string; + parent_version_id?: string; + was_auto_saved: string; + written_by_ai: string; +} + +export interface AutomationVersion extends AutomationVersionCreateOptions { + id: string; +} + +export type AutomationVersionUpdateOptions = Partial< + Omit +>; + export interface SynapseEntityCreateOptions { documentation: string; labels: string[]; diff --git a/apps/server/src/database/schemas/mysql.mts b/apps/server/src/database/schemas/mysql.mts index b768ff8..1124692 100644 --- a/apps/server/src/database/schemas/mysql.mts +++ b/apps/server/src/database/schemas/mysql.mts @@ -15,12 +15,12 @@ export const mysqlSharedVariablesTable = mysqlTable("shared_variables", { export const mysqlStoredAutomationTable = mysqlTable("stored_automation", { active: varchar("active", { length: 10 }).notNull(), // 'true'/'false' or '1'/'0' + active_version_id: varchar("active_version_id", { length: 36 }), area: varchar("area", { length: 100 }), body: text("body").notNull(), context: varchar("context", { length: 100 }).notNull(), create_date: timestamp("create_date").notNull(), documentation: text("documentation").notNull(), - draft: text("draft"), icon: varchar("icon", { length: 36 }), id: varchar("id", { length: 36 }).primaryKey().notNull(), // Stored as pipe-separated string @@ -28,7 +28,24 @@ export const mysqlStoredAutomationTable = mysqlTable("stored_automation", { last_update: timestamp("last_update").notNull(), parent: varchar("parent", { length: 36 }), title: varchar("title", { length: 255 }).notNull(), - version: varchar("version", { length: 50 }).notNull(), +}); + +export const mysqlAutomationVersionTable = mysqlTable("automation_versions", { + activated_from_version_id: varchar("activated_from_version_id", { length: 36 }), + automation_id: varchar("automation_id", { length: 36 }).notNull(), + body: text("body").notNull(), + date: timestamp("date").notNull(), + documentation: text("documentation"), + has_code_change: varchar("has_code_change", { length: 10 }).notNull().default("true"), + has_notes_change: varchar("has_notes_change", { length: 10 }).notNull().default("false"), + id: varchar("id", { length: 36 }).primaryKey().notNull(), + is_active: varchar("is_active", { length: 10 }).notNull().default("false"), + is_draft: varchar("is_draft", { length: 10 }).notNull().default("false"), + name: varchar("name", { length: 255 }), + notes: text("notes"), + parent_version_id: varchar("parent_version_id", { length: 36 }), + was_auto_saved: varchar("was_auto_saved", { length: 10 }).notNull().default("false"), + written_by_ai: varchar("written_by_ai", { length: 10 }).notNull().default("false"), }); export const mysqlSynapseEntitiesTable = mysqlTable("synapse_entities", { @@ -80,6 +97,12 @@ export type MysqlStoredAutomationSelect = InferSelectModel< export type MysqlStoredAutomationInsert = InferInsertModel< typeof mysqlStoredAutomationTable >; +export type MysqlAutomationVersionSelect = InferSelectModel< + typeof mysqlAutomationVersionTable +>; +export type MysqlAutomationVersionInsert = InferInsertModel< + typeof mysqlAutomationVersionTable +>; export type MysqlSynapseEntitySelect = InferSelectModel< typeof mysqlSynapseEntitiesTable >; diff --git a/apps/server/src/database/schemas/postgres.mts b/apps/server/src/database/schemas/postgres.mts index 6edc53b..1202c85 100644 --- a/apps/server/src/database/schemas/postgres.mts +++ b/apps/server/src/database/schemas/postgres.mts @@ -14,12 +14,12 @@ export const postgresSharedVariablesTable = pgTable("shared_variables", { export const postgresStoredAutomationTable = pgTable("stored_automation", { active: text("active").notNull(), + active_version_id: text("active_version_id"), area: text("area"), body: text("body").notNull(), context: text("context").notNull(), create_date: timestamp("create_date").notNull(), documentation: text("documentation").notNull(), - draft: text("draft"), icon: text("icon"), id: text("id").primaryKey().notNull(), labels: text("labels").notNull(), @@ -27,7 +27,24 @@ export const postgresStoredAutomationTable = pgTable("stored_automation", { last_update: timestamp("last_update").notNull(), parent: text("parent"), title: text("title").notNull(), - version: text("version").notNull(), +}); + +export const postgresAutomationVersionTable = pgTable("automation_versions", { + activated_from_version_id: text("activated_from_version_id"), + automation_id: text("automation_id").notNull(), + body: text("body").notNull(), + date: timestamp("date").notNull(), + documentation: text("documentation"), + has_code_change: text("has_code_change").notNull().default("true"), + has_notes_change: text("has_notes_change").notNull().default("false"), + id: text("id").primaryKey().notNull(), + is_active: text("is_active").notNull().default("false"), + is_draft: text("is_draft").notNull().default("false"), + name: text("name"), + notes: text("notes"), + parent_version_id: text("parent_version_id"), + was_auto_saved: text("was_auto_saved").notNull().default("false"), + written_by_ai: text("written_by_ai").notNull().default("false"), }); export const postgresSynapseEntitiesTable = pgTable("synapse_entities", { @@ -81,6 +98,12 @@ export type PostgresStoredAutomationSelect = InferSelectModel< export type PostgresStoredAutomationInsert = InferInsertModel< typeof postgresStoredAutomationTable >; +export type PostgresAutomationVersionSelect = InferSelectModel< + typeof postgresAutomationVersionTable +>; +export type PostgresAutomationVersionInsert = InferInsertModel< + typeof postgresAutomationVersionTable +>; export type PostgresSynapseEntitySelect = InferSelectModel< typeof postgresSynapseEntitiesTable >; diff --git a/apps/server/src/database/schemas/sqlite.mts b/apps/server/src/database/schemas/sqlite.mts index 653141c..f072f19 100644 --- a/apps/server/src/database/schemas/sqlite.mts +++ b/apps/server/src/database/schemas/sqlite.mts @@ -14,12 +14,12 @@ export const sqliteSharedVariablesTable = sqliteTable("shared_variables", { export const sqliteStoredAutomationTable = sqliteTable("stored_automation", { active: text("active").notNull(), + active_version_id: text("active_version_id"), area: text("area"), body: text("body").notNull(), context: text("context").notNull(), create_date: text("create_date").notNull(), documentation: text("documentation").notNull(), - draft: text("draft"), icon: text("icon"), id: text("id").primaryKey().notNull(), labels: text("labels").notNull(), @@ -27,7 +27,24 @@ export const sqliteStoredAutomationTable = sqliteTable("stored_automation", { last_update: text("last_update").notNull(), parent: text("parent"), title: text("title").notNull(), - version: text("version").notNull(), +}); + +export const sqliteAutomationVersionTable = sqliteTable("automation_versions", { + activated_from_version_id: text("activated_from_version_id"), + automation_id: text("automation_id").notNull(), + body: text("body").notNull(), + date: text("date").notNull(), + documentation: text("documentation"), + has_code_change: text("has_code_change").notNull().default("true"), + has_notes_change: text("has_notes_change").notNull().default("false"), + id: text("id").primaryKey().notNull(), + is_active: text("is_active").notNull().default("false"), + is_draft: text("is_draft").notNull().default("false"), + name: text("name"), + notes: text("notes"), + parent_version_id: text("parent_version_id"), + was_auto_saved: text("was_auto_saved").notNull().default("false"), + written_by_ai: text("written_by_ai").notNull().default("false"), }); export const sqliteSynapseEntitiesTable = sqliteTable("synapse_entities", { @@ -81,6 +98,12 @@ export type SqliteStoredAutomationSelect = InferSelectModel< export type SqliteStoredAutomationInsert = InferInsertModel< typeof sqliteStoredAutomationTable >; +export type SqliteAutomationVersionSelect = InferSelectModel< + typeof sqliteAutomationVersionTable +>; +export type SqliteAutomationVersionInsert = InferInsertModel< + typeof sqliteAutomationVersionTable +>; export type SqliteSynapseEntitySelect = InferSelectModel< typeof sqliteSynapseEntitiesTable >; diff --git a/apps/server/src/database/services/automation-version.service.mts b/apps/server/src/database/services/automation-version.service.mts new file mode 100644 index 0000000..0b2ca91 --- /dev/null +++ b/apps/server/src/database/services/automation-version.service.mts @@ -0,0 +1,348 @@ +import { TServiceParams } from "@digital-alchemy/core"; +import { and, eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { MySql2Database } from "drizzle-orm/mysql2"; +import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js"; +import { v4 } from "uuid"; + +import { + AutomationVersion, + AutomationVersionCreateOptions, + AutomationVersionUpdateOptions, +} from "../../utils/index.mts"; +import { + mysqlAutomationVersionTable, + postgresAutomationVersionTable, + sqliteAutomationVersionTable, +} from "../schemas/index.mts"; + +type VersionRow = { + id: string; + automation_id: string; + body: string; + date: string | Date; + documentation: string | null; + name: string | null; + notes: string | null; + is_active: string; + is_draft: string; + was_auto_saved: string; + written_by_ai: string; + has_code_change: string; + has_notes_change: string; + parent_version_id: string | null; + activated_from_version_id: string | null; +}; + +function loadRow(row: VersionRow): AutomationVersion { + return { + activatedFromVersionId: row.activated_from_version_id ?? undefined, + automationId: row.automation_id, + body: row.body, + date: row.date instanceof Date ? row.date.toISOString() : row.date, + documentation: row.documentation ?? undefined, + hasCodeChange: row.has_code_change === "true", + hasNotesChange: row.has_notes_change === "true", + id: row.id, + isActive: row.is_active === "true", + isDraft: row.is_draft === "true", + name: row.name ?? undefined, + notes: row.notes ?? undefined, + parentVersionId: row.parent_version_id ?? undefined, + wasAutoSaved: row.was_auto_saved === "true", + writtenByAi: row.written_by_ai === "true", + }; +} + +function saveRow(data: AutomationVersionCreateOptions & { id?: string }) { + return { + activated_from_version_id: data.activatedFromVersionId ?? null, + automation_id: data.automationId, + body: data.body, + date: data.date, + documentation: data.documentation ?? null, + has_code_change: data.hasCodeChange ? "true" : "false", + has_notes_change: data.hasNotesChange ? "true" : "false", + id: data.id ?? "", + is_active: data.isActive ? "true" : "false", + is_draft: data.isDraft ? "true" : "false", + name: data.name ?? null, + notes: data.notes ?? null, + parent_version_id: data.parentVersionId ?? null, + was_auto_saved: data.wasAutoSaved ? "true" : "false", + written_by_ai: data.writtenByAi ? "true" : "false", + }; +} + +function saveRowMysql(data: AutomationVersionCreateOptions & { id?: string }) { + return { + ...saveRow(data), + date: new Date(data.date), + }; +} + +export function AutomationVersionTable({ + lifecycle, + config, + synapse, + context, + metrics, +}: TServiceParams) { + const store = new Map(); + + lifecycle.onBootstrap(function () { + loadFromDB(); + }); + + const sqlite = { + async create(data: AutomationVersionCreateOptions) { + const database = synapse.database.getDatabase() as ReturnType; + const id = v4(); + const row = { ...saveRow(data), id }; + await database.insert(sqliteAutomationVersionTable).values(row); + const out = loadRow(row); + store.set(id, out); + return out; + }, + + async loadFromDB() { + const database = synapse.database.getDatabase() as ReturnType; + metrics.measure([context, "loadFromDB"], function () { + const rows = database.select().from(sqliteAutomationVersionTable).all(); + rows.forEach(function (row) { + const loaded = loadRow(row as VersionRow); + store.set(loaded.id, loaded); + }); + }); + }, + + async remove(id: string) { + const database = synapse.database.getDatabase() as ReturnType; + store.delete(id); + await database + .delete(sqliteAutomationVersionTable) + .where(eq(sqliteAutomationVersionTable.id, id)); + }, + + async removeForAutomation(automationId: string) { + const database = synapse.database.getDatabase() as ReturnType; + for (const [id, v] of store) { + if (v.automationId === automationId) store.delete(id); + } + await database + .delete(sqliteAutomationVersionTable) + .where(eq(sqliteAutomationVersionTable.automation_id, automationId)); + }, + + async update(id: string, data: AutomationVersionUpdateOptions) { + const database = synapse.database.getDatabase() as ReturnType; + const current = store.get(id); + if (!current) return undefined; + const merged = { ...current, ...data }; + const row = { ...saveRow(merged as AutomationVersionCreateOptions), id }; + await database + .update(sqliteAutomationVersionTable) + .set(row) + .where(eq(sqliteAutomationVersionTable.id, id)); + const out = loadRow(row); + store.set(id, out); + return out; + }, + }; + + const mysql = { + async create(data: AutomationVersionCreateOptions) { + const database = synapse.database.getDatabase() as MySql2Database>; + const id = v4(); + const row = { ...saveRowMysql(data), id }; + await database.insert(mysqlAutomationVersionTable).values(row); + const out = loadRow({ ...row, date: row.date.toISOString() }); + store.set(id, out); + return out; + }, + + async loadFromDB() { + const database = synapse.database.getDatabase() as MySql2Database>; + metrics.measure([context, "loadFromDB"], async function () { + const rows = await database.select().from(mysqlAutomationVersionTable).execute(); + rows.forEach(function (row) { + const loaded = loadRow(row as unknown as VersionRow); + store.set(loaded.id, loaded); + }); + }); + }, + + async remove(id: string) { + const database = synapse.database.getDatabase() as MySql2Database>; + store.delete(id); + await database + .delete(mysqlAutomationVersionTable) + .where(eq(mysqlAutomationVersionTable.id, id)); + }, + + async removeForAutomation(automationId: string) { + const database = synapse.database.getDatabase() as MySql2Database>; + for (const [id, v] of store) { + if (v.automationId === automationId) store.delete(id); + } + await database + .delete(mysqlAutomationVersionTable) + .where(eq(mysqlAutomationVersionTable.automation_id, automationId)); + }, + + async update(id: string, data: AutomationVersionUpdateOptions) { + const database = synapse.database.getDatabase() as MySql2Database>; + const current = store.get(id); + if (!current) return undefined; + const merged = { ...current, ...data }; + const row = { ...saveRowMysql(merged as AutomationVersionCreateOptions), id }; + await database + .update(mysqlAutomationVersionTable) + .set(row) + .where(eq(mysqlAutomationVersionTable.id, id)); + const out = loadRow({ ...row, date: row.date.toISOString() }); + store.set(id, out); + return out; + }, + }; + + const postgres = { + async create(data: AutomationVersionCreateOptions) { + const database = synapse.database.getDatabase() as ReturnType; + const id = v4(); + const row = { ...saveRowMysql(data), id }; + await database.insert(postgresAutomationVersionTable).values(row); + const out = loadRow({ ...row, date: row.date.toISOString() }); + store.set(id, out); + return out; + }, + + async loadFromDB() { + const database = synapse.database.getDatabase() as ReturnType; + metrics.measure([context, "loadFromDB"], async function () { + const rows = await database.select().from(postgresAutomationVersionTable).execute(); + rows.forEach(function (row) { + const loaded = loadRow(row as unknown as VersionRow); + store.set(loaded.id, loaded); + }); + }); + }, + + async remove(id: string) { + const database = synapse.database.getDatabase() as ReturnType; + store.delete(id); + await database + .delete(postgresAutomationVersionTable) + .where(eq(postgresAutomationVersionTable.id, id)); + }, + + async removeForAutomation(automationId: string) { + const database = synapse.database.getDatabase() as ReturnType; + for (const [id, v] of store) { + if (v.automationId === automationId) store.delete(id); + } + await database + .delete(postgresAutomationVersionTable) + .where(eq(postgresAutomationVersionTable.automation_id, automationId)); + }, + + async update(id: string, data: AutomationVersionUpdateOptions) { + const database = synapse.database.getDatabase() as ReturnType; + const current = store.get(id); + if (!current) return undefined; + const merged = { ...current, ...data }; + const row = { ...saveRowMysql(merged as AutomationVersionCreateOptions), id }; + await database + .update(postgresAutomationVersionTable) + .set(row) + .where(eq(postgresAutomationVersionTable.id, id)); + const out = loadRow({ ...row, date: row.date.toISOString() }); + store.set(id, out); + return out; + }, + }; + + async function loadFromDB() { + const dbType = config.synapse.DATABASE_TYPE; + switch (dbType) { + case "mysql": + await mysql.loadFromDB(); + break; + case "postgresql": + await postgres.loadFromDB(); + break; + case "sqlite": + default: + await sqlite.loadFromDB(); + break; + } + } + + async function create(data: AutomationVersionCreateOptions) { + const dbType = config.synapse.DATABASE_TYPE; + switch (dbType) { + case "mysql": + return await mysql.create(data); + case "postgresql": + return await postgres.create(data); + case "sqlite": + default: + return await sqlite.create(data); + } + } + + async function update(id: string, data: AutomationVersionUpdateOptions) { + const dbType = config.synapse.DATABASE_TYPE; + switch (dbType) { + case "mysql": + return await mysql.update(id, data); + case "postgresql": + return await postgres.update(id, data); + case "sqlite": + default: + return await sqlite.update(id, data); + } + } + + async function remove(id: string) { + const dbType = config.synapse.DATABASE_TYPE; + switch (dbType) { + case "mysql": + await mysql.remove(id); + break; + case "postgresql": + await postgres.remove(id); + break; + case "sqlite": + default: + await sqlite.remove(id); + break; + } + } + + async function removeForAutomation(automationId: string) { + const dbType = config.synapse.DATABASE_TYPE; + switch (dbType) { + case "mysql": + await mysql.removeForAutomation(automationId); + break; + case "postgresql": + await postgres.removeForAutomation(automationId); + break; + case "sqlite": + default: + await sqlite.removeForAutomation(automationId); + break; + } + } + + function get(id: string): AutomationVersion | undefined { + return store.get(id); + } + + function listForAutomation(automationId: string): AutomationVersion[] { + return [...store.values()].filter((v) => v.automationId === automationId); + } + + return { create, get, listForAutomation, loadFromDB, remove, removeForAutomation, update }; +} diff --git a/apps/server/src/database/services/automation.service.mts b/apps/server/src/database/services/automation.service.mts index 759ec14..c4ac934 100644 --- a/apps/server/src/database/services/automation.service.mts +++ b/apps/server/src/database/services/automation.service.mts @@ -44,10 +44,11 @@ export function AutomationTable({ return out; }, - load(row: Partial): StoredAutomation { + load(row: Partial & { active_version_id?: string | null }): StoredAutomation { return { ...row, active: row.active === "true", + activeVersionId: row.active_version_id ?? undefined, labels: row.labels?.split("|") || [], } as StoredAutomation; }, @@ -80,19 +81,18 @@ export function AutomationTable({ const now = new Date().toISOString(); return { active: data.active ? "true" : "false", + active_version_id: data.activeVersionId ?? null, area: data.area, body: data.body, context: data.context, create_date: (data as StoredAutomation).createDate ?? now, documentation: data.documentation, - draft: data.draft, icon: data.icon, id: (data as StoredAutomation).id || "", labels: data.labels.join("|"), last_update: now, parent: data.parent, title: data.title, - version: data.version, }; }, @@ -136,10 +136,11 @@ export function AutomationTable({ return out; }, - load(row: Partial): StoredAutomation { + load(row: Partial & { active_version_id?: string | null }): StoredAutomation { return { ...row, active: row.active === "true", + activeVersionId: row.active_version_id ?? undefined, labels: row.labels?.split("|") || [], } as StoredAutomation; }, @@ -175,6 +176,7 @@ export function AutomationTable({ const now = new Date(); return { active: data.active ? "true" : "false", + active_version_id: data.activeVersionId ?? null, area: data.area, body: data.body, context: data.context, @@ -182,14 +184,12 @@ export function AutomationTable({ ? new Date((data as StoredAutomation).createDate) : now, documentation: data.documentation, - draft: data.draft, icon: data.icon, id: (data as StoredAutomation).id || "", labels: data.labels.join("|"), last_update: now, parent: data.parent, title: data.title, - version: data.version, }; }, @@ -233,10 +233,11 @@ export function AutomationTable({ return out; }, - load(row: Partial): StoredAutomation { + load(row: Partial & { active_version_id?: string | null }): StoredAutomation { return { ...row, active: row.active === "true", + activeVersionId: row.active_version_id ?? undefined, labels: row.labels?.split("|") || [], } as StoredAutomation; }, @@ -272,6 +273,7 @@ export function AutomationTable({ const now = new Date(); return { active: data.active ? "true" : "false", + active_version_id: data.activeVersionId ?? null, area: data.area, body: data.body, context: data.context, @@ -279,14 +281,12 @@ export function AutomationTable({ ? new Date((data as StoredAutomation).createDate) : now, documentation: data.documentation, - draft: data.draft, icon: data.icon, id: (data as StoredAutomation).id || "", labels: data.labels.join("|"), last_update: now, parent: data.parent, title: data.title, - version: data.version, }; }, diff --git a/apps/server/src/database/services/index.mts b/apps/server/src/database/services/index.mts index ad29b58..3190a23 100644 --- a/apps/server/src/database/services/index.mts +++ b/apps/server/src/database/services/index.mts @@ -1,3 +1,4 @@ +export * from "./automation-version.service.mts"; export * from "./automation.service.mts"; export * from "./entities.service.mts"; export * from "./internal.service.mts"; diff --git a/apps/server/src/utils/contracts/automation.mts b/apps/server/src/utils/contracts/automation.mts index 544d2d7..277452b 100644 --- a/apps/server/src/utils/contracts/automation.mts +++ b/apps/server/src/utils/contracts/automation.mts @@ -9,6 +9,7 @@ import { Type } from "@sinclair/typebox"; export const StoredAutomation = Type.Object( { active: Type.Boolean({ description: "Should the code in this be running" }), + activeVersionId: Type.Optional(Type.String({ description: "ID of the currently active version" })), area: Type.Optional(Type.String({ description: "Home Assistant area_id" })), body: Type.String({ description: "Function body, in Typescript" }), context: Type.String({ description: "Log context" }), @@ -16,7 +17,6 @@ export const StoredAutomation = Type.Object( documentation: Type.String({ description: "User provided markdown notes", }), - draft: Type.Optional(Type.String({ description: "Draft edits" })), icon: Type.Optional(Type.String({ description: "Icon for UI" })), id: Type.String({ description: "UUID" }), labels: Type.Array(Type.String(), { @@ -29,9 +29,6 @@ export const StoredAutomation = Type.Object( }), ), title: Type.String({ description: "Human readable title" }), - version: Type.String({ - description: "User declared version", - }), }, { description: "Used to store the actual automation on disk" }, ); @@ -60,6 +57,36 @@ export const StoredAutomationRow = Type.Intersect([ ]); export type StoredAutomationRow = typeof StoredAutomationRow.static; +export const AutomationVersion = Type.Object( + { + activatedFromVersionId: Type.Optional(Type.String({ description: "Version this was activated from" })), + automationId: Type.String({ description: "Parent automation UUID" }), + body: Type.String({ description: "TypeScript code at this version" }), + date: Type.String({ description: "ISO timestamp of creation" }), + documentation: Type.Optional(Type.String({ description: "Snapshot of automation docs at save time" })), + hasCodeChange: Type.Boolean({ description: "Did body change vs parent?" }), + hasNotesChange: Type.Boolean({ description: "Did documentation change vs parent?" }), + id: Type.String({ description: "UUID" }), + isActive: Type.Boolean({ description: "Is this the active running version?" }), + isDraft: Type.Boolean({ description: "Is this an unsaved draft?" }), + name: Type.Optional(Type.String({ description: "Optional version name" })), + notes: Type.Optional(Type.String({ description: "Optional commit-style notes" })), + parentVersionId: Type.Optional(Type.String({ description: "Previous version in the chain" })), + wasAutoSaved: Type.Boolean({ description: "Was this auto-saved after idle period?" }), + writtenByAi: Type.Boolean({ description: "Was this version written by AI?" }), + }, + { description: "A single version snapshot of an automation" }, +); +export type AutomationVersion = typeof AutomationVersion.static; + +export const AutomationVersionCreateOptions = Type.Omit(AutomationVersion, ["id"]); +export type AutomationVersionCreateOptions = typeof AutomationVersionCreateOptions.static; + +export const AutomationVersionUpdateOptions = Type.Partial( + Type.Omit(AutomationVersionCreateOptions, ["automationId"]), +); +export type AutomationVersionUpdateOptions = typeof AutomationVersionUpdateOptions.static; + export type AutomationTeardown = { register(remove: RemoveCallback, type: string): void; teardown(): void; From d8a9c20af790c68429e90269c9736518082d5f74 Mon Sep 17 00:00:00 2001 From: Chris Drackett Date: Wed, 8 Apr 2026 16:04:46 -0700 Subject: [PATCH 2/4] update automation store --- apps/client/src/store/automation.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/client/src/store/automation.ts b/apps/client/src/store/automation.ts index 6aebe57..c216b6b 100644 --- a/apps/client/src/store/automation.ts +++ b/apps/client/src/store/automation.ts @@ -30,6 +30,10 @@ export const emptyAutomation: AutomationType = { * Is this automation turned on and running? */ active: false, + /** + * ID of the currently active version. + */ + activeVersionId: "", /** * What HASS area is this automation associated with? */ @@ -50,10 +54,6 @@ export const emptyAutomation: AutomationType = { * Markdown documentation for the automation. */ documentation: "", - /** - * draft of the next automation update. - */ - draft: "", /** * Icon/emoji used to identify the automation. */ @@ -75,10 +75,6 @@ export const emptyAutomation: AutomationType = { * Title of the automation. */ title: "", - /** - * Not yet used - */ - version: "", } /** From 854cd47a5625251273cdf7161de0d0325673fed1 Mon Sep 17 00:00:00 2001 From: Chris Drackett Date: Wed, 8 Apr 2026 16:55:36 -0700 Subject: [PATCH 3/4] =?UTF-8?q?remove=20activatedFromVersionId=20as=20I?= =?UTF-8?q?=E2=80=99ve=20decided=20not=20to=20do=20that=20for=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/controllers/automation-version.controller.mts | 1 - apps/server/src/app/services/automation-version.service.mts | 1 - apps/server/src/database/schemas/common.mts | 1 - apps/server/src/database/schemas/mysql.mts | 1 - apps/server/src/database/schemas/postgres.mts | 1 - apps/server/src/database/schemas/sqlite.mts | 1 - .../src/database/services/automation-version.service.mts | 3 --- apps/server/src/utils/contracts/automation.mts | 1 - 8 files changed, 10 deletions(-) diff --git a/apps/server/src/app/controllers/automation-version.controller.mts b/apps/server/src/app/controllers/automation-version.controller.mts index 907b963..ed25cd2 100644 --- a/apps/server/src/app/controllers/automation-version.controller.mts +++ b/apps/server/src/app/controllers/automation-version.controller.mts @@ -11,7 +11,6 @@ const CreateDraftBody = Type.Object({ const UpdateDraftBody = Type.Object({ body: Type.Optional(Type.String()), - documentation: Type.Optional(Type.String()), name: Type.Optional(Type.String()), notes: Type.Optional(Type.String()), }); diff --git a/apps/server/src/app/services/automation-version.service.mts b/apps/server/src/app/services/automation-version.service.mts index bb2ff2d..0b97291 100644 --- a/apps/server/src/app/services/automation-version.service.mts +++ b/apps/server/src/app/services/automation-version.service.mts @@ -93,7 +93,6 @@ export function AutomationVersionLogic({ } return await database.automationVersion.create({ - activatedFromVersionId: undefined, automationId, body, date: new Date().toISOString(), diff --git a/apps/server/src/database/schemas/common.mts b/apps/server/src/database/schemas/common.mts index 656dd0d..b2fe716 100644 --- a/apps/server/src/database/schemas/common.mts +++ b/apps/server/src/database/schemas/common.mts @@ -40,7 +40,6 @@ export type StoredAutomationRow = Omit & { }; export interface AutomationVersionCreateOptions { - activated_from_version_id?: string; automation_id: string; body: string; date: string; diff --git a/apps/server/src/database/schemas/mysql.mts b/apps/server/src/database/schemas/mysql.mts index 1124692..fd04c2c 100644 --- a/apps/server/src/database/schemas/mysql.mts +++ b/apps/server/src/database/schemas/mysql.mts @@ -31,7 +31,6 @@ export const mysqlStoredAutomationTable = mysqlTable("stored_automation", { }); export const mysqlAutomationVersionTable = mysqlTable("automation_versions", { - activated_from_version_id: varchar("activated_from_version_id", { length: 36 }), automation_id: varchar("automation_id", { length: 36 }).notNull(), body: text("body").notNull(), date: timestamp("date").notNull(), diff --git a/apps/server/src/database/schemas/postgres.mts b/apps/server/src/database/schemas/postgres.mts index 1202c85..a847720 100644 --- a/apps/server/src/database/schemas/postgres.mts +++ b/apps/server/src/database/schemas/postgres.mts @@ -30,7 +30,6 @@ export const postgresStoredAutomationTable = pgTable("stored_automation", { }); export const postgresAutomationVersionTable = pgTable("automation_versions", { - activated_from_version_id: text("activated_from_version_id"), automation_id: text("automation_id").notNull(), body: text("body").notNull(), date: timestamp("date").notNull(), diff --git a/apps/server/src/database/schemas/sqlite.mts b/apps/server/src/database/schemas/sqlite.mts index f072f19..c80be5c 100644 --- a/apps/server/src/database/schemas/sqlite.mts +++ b/apps/server/src/database/schemas/sqlite.mts @@ -30,7 +30,6 @@ export const sqliteStoredAutomationTable = sqliteTable("stored_automation", { }); export const sqliteAutomationVersionTable = sqliteTable("automation_versions", { - activated_from_version_id: text("activated_from_version_id"), automation_id: text("automation_id").notNull(), body: text("body").notNull(), date: text("date").notNull(), diff --git a/apps/server/src/database/services/automation-version.service.mts b/apps/server/src/database/services/automation-version.service.mts index 0b2ca91..2dcfad1 100644 --- a/apps/server/src/database/services/automation-version.service.mts +++ b/apps/server/src/database/services/automation-version.service.mts @@ -31,12 +31,10 @@ type VersionRow = { has_code_change: string; has_notes_change: string; parent_version_id: string | null; - activated_from_version_id: string | null; }; function loadRow(row: VersionRow): AutomationVersion { return { - activatedFromVersionId: row.activated_from_version_id ?? undefined, automationId: row.automation_id, body: row.body, date: row.date instanceof Date ? row.date.toISOString() : row.date, @@ -56,7 +54,6 @@ function loadRow(row: VersionRow): AutomationVersion { function saveRow(data: AutomationVersionCreateOptions & { id?: string }) { return { - activated_from_version_id: data.activatedFromVersionId ?? null, automation_id: data.automationId, body: data.body, date: data.date, diff --git a/apps/server/src/utils/contracts/automation.mts b/apps/server/src/utils/contracts/automation.mts index 277452b..bf15418 100644 --- a/apps/server/src/utils/contracts/automation.mts +++ b/apps/server/src/utils/contracts/automation.mts @@ -59,7 +59,6 @@ export type StoredAutomationRow = typeof StoredAutomationRow.static; export const AutomationVersion = Type.Object( { - activatedFromVersionId: Type.Optional(Type.String({ description: "Version this was activated from" })), automationId: Type.String({ description: "Parent automation UUID" }), body: Type.String({ description: "TypeScript code at this version" }), date: Type.String({ description: "ISO timestamp of creation" }), From 935d4359221c864ffd945fed09b3f5c9519c229a Mon Sep 17 00:00:00 2001 From: Chris Drackett Date: Thu, 9 Apr 2026 12:37:10 -0700 Subject: [PATCH 4/4] update store for client side --- apps/client/src/store/automation.ts | 153 ++++++++++++++++++ apps/client/src/store/automationVersion.ts | 50 ++++++ apps/client/src/store/index.ts | 21 ++- .../services/automation-version.service.mts | 27 +--- apps/server/src/database/schemas/common.mts | 5 +- apps/server/src/database/schemas/mysql.mts | 6 +- apps/server/src/database/schemas/postgres.mts | 6 +- apps/server/src/database/schemas/sqlite.mts | 6 +- .../services/automation-version.service.mts | 25 +-- .../database/services/automation.service.mts | 121 ++++++-------- .../server/src/utils/contracts/automation.mts | 5 +- 11 files changed, 288 insertions(+), 137 deletions(-) create mode 100644 apps/client/src/store/automationVersion.ts diff --git a/apps/client/src/store/automation.ts b/apps/client/src/store/automation.ts index c216b6b..7a4cbf6 100644 --- a/apps/client/src/store/automation.ts +++ b/apps/client/src/store/automation.ts @@ -4,10 +4,12 @@ import { proxyMap } from "valtio/utils" import { Text } from "@code-glue/paradigm" import { baseUrl } from "../utils/baseUrl" +import { type AutomationVersion, versionFactory } from "./automationVersion" import type { AutomationCreateOptions as ServerAutomationCreateOptions, AutomationUpdateOptions as ServerAutomationUpdateOptions, + AutomationVersion as ServerAutomationVersion, StoredAutomation as ServerStoredAutomation, } from "@code-glue/server/utils/contracts/automation.mts" @@ -16,16 +18,22 @@ type ClientOnlyState = { * Has the automation been edited since last save? */ _isEdited: boolean + /** + * Version history for this automation, keyed by version ID. + */ + _versions: Map } type RequiredServerStoredAutomation = Required type AutomationType = RequiredServerStoredAutomation & ClientOnlyState + type AutomationUpdateOptions = ServerAutomationUpdateOptions & Partial export const emptyAutomation: AutomationType = { _isEdited: false, + _versions: new Map(), /** * Is this automation turned on and running? */ @@ -157,6 +165,150 @@ const automationFactory = createFactory>( this.push() }, }) + .actions({ + /** Insert or update a version in-place so Valtio subscriptions stay stable. */ + upsertVersion(data: ServerAutomationVersion): AutomationVersion { + const existing = this._versions.get(data.id) + if (existing) { + Object.assign(existing, data) + return existing + } + const created = versionFactory.create(undefined, data) + this._versions.set(data.id, created) + return created + }, + getDraftVersion(): AutomationVersion | undefined { + return [...this._versions.values()].find((v) => v.isDraft) + }, + getActiveVersion(): AutomationVersion | undefined { + return [...this._versions.values()].find((v) => v.isActive) + }, + getVersions(): AutomationVersion[] { + return [...this._versions.values()] + }, + }) + .actions({ + async fetchVersions(): Promise { + await fetch(`${baseUrl}/api/v1/automation/${this.id}/versions`, { + method: "GET", + }) + .then((r) => { + if (!r.ok) throw new Error(`Failed to fetch versions: ${r.status}`) + return r.json() + }) + .then((versions: ServerAutomationVersion[]) => { + versions.forEach((v) => { + this.upsertVersion(v) + }) + }) + .catch((error) => { + console.error( + "Failed to fetch versions for automation", + this.id, + error, + ) + }) + }, + async createDraftVersion( + body: string, + parentVersionId?: string, + ): Promise { + return await fetch(`${baseUrl}/api/v1/automation/${this.id}/versions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body, parentVersionId }), + }) + .then((r) => { + if (!r.ok) throw new Error(`Failed to create draft: ${r.status}`) + return r.json() + }) + .then((version: ServerAutomationVersion) => { + this.upsertVersion(version) + return this._versions.get(version.id) + }) + .catch((error) => { + console.error("Failed to create draft version", error) + return undefined + }) + }, + async finalizeVersion( + versionId: string, + opts: { + name?: string + notes?: string + wasAutoSaved: boolean + makeActive?: boolean + }, + ): Promise { + return await fetch( + `${baseUrl}/api/v1/automation/${this.id}/versions/${versionId}/finalize`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(opts), + }, + ) + .then((r) => { + if (!r.ok) throw new Error(`Failed to finalize version: ${r.status}`) + return r.json() + }) + .then((version: ServerAutomationVersion) => { + this.upsertVersion(version) + if (opts.makeActive !== false) { + for (const v of this._versions.values()) { + if (v.id !== versionId && v.isActive) v.isActive = false + } + this.activeVersionId = version.id + this.body = version.body + } + return this._versions.get(versionId) + }) + .catch((error) => { + console.error("Failed to finalize version", error) + return undefined + }) + }, + async activateVersion( + versionId: string, + ): Promise { + return await fetch( + `${baseUrl}/api/v1/automation/${this.id}/versions/${versionId}/activate`, + { method: "POST" }, + ) + .then((r) => { + if (!r.ok) throw new Error(`Failed to activate version: ${r.status}`) + return r.json() + }) + .then((activatedVersion: ServerAutomationVersion) => { + for (const v of this._versions.values()) { + if (v.isActive) v.isActive = false + } + this.upsertVersion(activatedVersion) + this.activeVersionId = activatedVersion.id + this.body = activatedVersion.body + return this._versions.get(versionId) + }) + .catch((error) => { + console.error("Failed to activate version", error) + return undefined + }) + }, + async deleteVersion(versionId: string): Promise { + await fetch( + `${baseUrl}/api/v1/automation/${this.id}/versions/${versionId}`, + { method: "DELETE" }, + ) + .then((r) => { + if (!r.ok) throw new Error(`Failed to delete version: ${r.status}`) + }) + .then(() => { + this._versions.delete(versionId) + }) + .catch((error) => { + console.error("Failed to delete version", error) + }) + }, + }) export const createLocalAutomation = ( initialData: Partial = emptyAutomation, @@ -166,6 +318,7 @@ export const createLocalAutomation = ( { id: uuid(), ...initialData, + _versions: proxyMap([]), createDate: getNowISO(), lastUpdate: getNowISO(), }, diff --git a/apps/client/src/store/automationVersion.ts b/apps/client/src/store/automationVersion.ts new file mode 100644 index 0000000..c136ccc --- /dev/null +++ b/apps/client/src/store/automationVersion.ts @@ -0,0 +1,50 @@ +import { createFactory, type Store } from "@tiltshift/valtio-factory" + +import { baseUrl } from "../utils/baseUrl" + +import type { + AutomationVersionUpdateOptions, + AutomationVersion as ServerAutomationVersion, +} from "@code-glue/server/utils/contracts/automation.mts" + +const emptyVersion: ServerAutomationVersion = { + automationId: "", + body: "", + createDate: "", + id: "", + isActive: false, + isDraft: false, + name: "", + notes: "", + parentVersionId: "", + wasAutoSaved: false, + writtenByAi: false, +} + +export const versionFactory = createFactory( + emptyVersion, +).actions({ + async update(updates: AutomationVersionUpdateOptions): Promise { + Object.assign(this, updates) + await fetch( + `${baseUrl}/api/v1/automation/${this.automationId}/versions/${this.id}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }, + ) + .then((r) => { + if (!r.ok) throw new Error(`Failed to update version: ${r.status}`) + return r.json() + }) + .then((version: ServerAutomationVersion) => { + Object.assign(this, version) + }) + .catch((error) => { + console.error("Failed to update version", error) + }) + }, +}) + +export type AutomationVersion = Store diff --git a/apps/client/src/store/index.ts b/apps/client/src/store/index.ts index bf66909..e8a28ee 100644 --- a/apps/client/src/store/index.ts +++ b/apps/client/src/store/index.ts @@ -74,19 +74,25 @@ const setupStore = async () => { const getAutomationsFromServer = async () => { return await fetch(`${baseUrl}/api/v1/automation`, { method: "GET" }) .then((response) => response.json()) - .then((json: StoredAutomation[]) => { + .then(async (json: StoredAutomation[]) => { + const versionPromises: Promise[] = [] + json.forEach((serverAutomation) => { - const existingLocalAutomation = store.automations.get( - serverAutomation.id, - ) + let automation = store.automations.get(serverAutomation.id) - if (!existingLocalAutomation) { - createLocalAutomation(serverAutomation) + if (!automation) { + automation = createLocalAutomation(serverAutomation) } else { - Object.assign(existingLocalAutomation, serverAutomation) + Object.assign(automation, serverAutomation) } + + versionPromises.push(automation.fetchVersions()) }) + + // Unblock the UI as soon as automations are in the store. + // Version fetches continue in the background. store.apiStatus.automationsReady = true + Promise.all(versionPromises) }) } @@ -146,3 +152,4 @@ async function initializeApp() { initializeApp() export * from "./automation" +export type { AutomationVersion } from "./automationVersion" diff --git a/apps/server/src/app/services/automation-version.service.mts b/apps/server/src/app/services/automation-version.service.mts index 0b97291..4ae223c 100644 --- a/apps/server/src/app/services/automation-version.service.mts +++ b/apps/server/src/app/services/automation-version.service.mts @@ -46,13 +46,9 @@ export function AutomationVersionLogic({ }); const version = await database.automationVersion.create({ - activatedFromVersionId: undefined, automationId: automation.id, body: automation.body, - date, - documentation: automation.documentation, - hasCodeChange: true, - hasNotesChange: false, + createDate: date, isActive: true, isDraft: false, name: `Initial version — ${formattedDate}`, @@ -87,18 +83,10 @@ export function AutomationVersionLogic({ body: string, parentVersionId: string | undefined, ) { - const automation = database.automation.get(automationId); - if (!automation) { - throw new Error(`Automation ${automationId} not found`); - } - return await database.automationVersion.create({ automationId, body, - date: new Date().toISOString(), - documentation: automation.documentation, - hasCodeChange: true, - hasNotesChange: false, + createDate: new Date().toISOString(), isActive: false, isDraft: true, name: undefined, @@ -121,7 +109,7 @@ export function AutomationVersionLogic({ const updates: AutomationVersionUpdateOptions = {}; if (opts.body !== undefined) { updates.body = opts.body; - updates.date = new Date().toISOString(); + updates.createDate = new Date().toISOString(); } if (opts.name !== undefined) updates.name = opts.name; if (opts.notes !== undefined) updates.notes = opts.notes; @@ -159,12 +147,7 @@ export function AutomationVersionLogic({ throw new Error(`Automation ${version.automationId} not found`); } - // Snapshot current documentation at finalize time - const hasNotesChange = version.documentation !== automation.documentation; - const updated = await database.automationVersion.update(versionId, { - documentation: automation.documentation, - hasNotesChange, isActive: makeActive, isDraft: false, name: opts.name, @@ -237,8 +220,8 @@ export function AutomationVersionLogic({ body: targetVersion.body, } as never); - const versionLabel = (v: { name?: string; date: string }) => - v.name ? `"${v.name}"` : new Date(v.date).toLocaleString(); + const versionLabel = (v: { name?: string; createDate: string }) => + v.name ? `"${v.name}"` : new Date(v.createDate).toLocaleString(); const previousLabel = currentActive ? versionLabel(currentActive) : "unknown"; const targetLabel = versionLabel(targetVersion); automationLogger(automation).info( diff --git a/apps/server/src/database/schemas/common.mts b/apps/server/src/database/schemas/common.mts index b2fe716..441e9ec 100644 --- a/apps/server/src/database/schemas/common.mts +++ b/apps/server/src/database/schemas/common.mts @@ -42,10 +42,7 @@ export type StoredAutomationRow = Omit & { export interface AutomationVersionCreateOptions { automation_id: string; body: string; - date: string; - documentation?: string; - has_code_change: string; - has_notes_change: string; + createDate: string; is_active: string; is_draft: string; name?: string; diff --git a/apps/server/src/database/schemas/mysql.mts b/apps/server/src/database/schemas/mysql.mts index fd04c2c..c6a6151 100644 --- a/apps/server/src/database/schemas/mysql.mts +++ b/apps/server/src/database/schemas/mysql.mts @@ -28,15 +28,13 @@ export const mysqlStoredAutomationTable = mysqlTable("stored_automation", { last_update: timestamp("last_update").notNull(), parent: varchar("parent", { length: 36 }), title: varchar("title", { length: 255 }).notNull(), + version: varchar("version", { length: 50 }).notNull(), }); export const mysqlAutomationVersionTable = mysqlTable("automation_versions", { automation_id: varchar("automation_id", { length: 36 }).notNull(), body: text("body").notNull(), - date: timestamp("date").notNull(), - documentation: text("documentation"), - has_code_change: varchar("has_code_change", { length: 10 }).notNull().default("true"), - has_notes_change: varchar("has_notes_change", { length: 10 }).notNull().default("false"), + createDate: timestamp("date").notNull(), id: varchar("id", { length: 36 }).primaryKey().notNull(), is_active: varchar("is_active", { length: 10 }).notNull().default("false"), is_draft: varchar("is_draft", { length: 10 }).notNull().default("false"), diff --git a/apps/server/src/database/schemas/postgres.mts b/apps/server/src/database/schemas/postgres.mts index a847720..00e15ef 100644 --- a/apps/server/src/database/schemas/postgres.mts +++ b/apps/server/src/database/schemas/postgres.mts @@ -27,15 +27,13 @@ export const postgresStoredAutomationTable = pgTable("stored_automation", { last_update: timestamp("last_update").notNull(), parent: text("parent"), title: text("title").notNull(), + version: text("version").notNull(), }); export const postgresAutomationVersionTable = pgTable("automation_versions", { automation_id: text("automation_id").notNull(), body: text("body").notNull(), - date: timestamp("date").notNull(), - documentation: text("documentation"), - has_code_change: text("has_code_change").notNull().default("true"), - has_notes_change: text("has_notes_change").notNull().default("false"), + createDate: timestamp("date").notNull(), id: text("id").primaryKey().notNull(), is_active: text("is_active").notNull().default("false"), is_draft: text("is_draft").notNull().default("false"), diff --git a/apps/server/src/database/schemas/sqlite.mts b/apps/server/src/database/schemas/sqlite.mts index c80be5c..a6530a9 100644 --- a/apps/server/src/database/schemas/sqlite.mts +++ b/apps/server/src/database/schemas/sqlite.mts @@ -27,15 +27,13 @@ export const sqliteStoredAutomationTable = sqliteTable("stored_automation", { last_update: text("last_update").notNull(), parent: text("parent"), title: text("title").notNull(), + version: text("version").notNull(), }); export const sqliteAutomationVersionTable = sqliteTable("automation_versions", { automation_id: text("automation_id").notNull(), body: text("body").notNull(), - date: text("date").notNull(), - documentation: text("documentation"), - has_code_change: text("has_code_change").notNull().default("true"), - has_notes_change: text("has_notes_change").notNull().default("false"), + createDate: text("date").notNull(), id: text("id").primaryKey().notNull(), is_active: text("is_active").notNull().default("false"), is_draft: text("is_draft").notNull().default("false"), diff --git a/apps/server/src/database/services/automation-version.service.mts b/apps/server/src/database/services/automation-version.service.mts index 2dcfad1..432fcd3 100644 --- a/apps/server/src/database/services/automation-version.service.mts +++ b/apps/server/src/database/services/automation-version.service.mts @@ -20,16 +20,13 @@ type VersionRow = { id: string; automation_id: string; body: string; - date: string | Date; - documentation: string | null; + createDate: string | Date; name: string | null; notes: string | null; is_active: string; is_draft: string; was_auto_saved: string; written_by_ai: string; - has_code_change: string; - has_notes_change: string; parent_version_id: string | null; }; @@ -37,10 +34,7 @@ function loadRow(row: VersionRow): AutomationVersion { return { automationId: row.automation_id, body: row.body, - date: row.date instanceof Date ? row.date.toISOString() : row.date, - documentation: row.documentation ?? undefined, - hasCodeChange: row.has_code_change === "true", - hasNotesChange: row.has_notes_change === "true", + createDate: row.createDate instanceof Date ? row.createDate.toISOString() : row.createDate, id: row.id, isActive: row.is_active === "true", isDraft: row.is_draft === "true", @@ -56,10 +50,7 @@ function saveRow(data: AutomationVersionCreateOptions & { id?: string }) { return { automation_id: data.automationId, body: data.body, - date: data.date, - documentation: data.documentation ?? null, - has_code_change: data.hasCodeChange ? "true" : "false", - has_notes_change: data.hasNotesChange ? "true" : "false", + createDate: data.createDate, id: data.id ?? "", is_active: data.isActive ? "true" : "false", is_draft: data.isDraft ? "true" : "false", @@ -74,7 +65,7 @@ function saveRow(data: AutomationVersionCreateOptions & { id?: string }) { function saveRowMysql(data: AutomationVersionCreateOptions & { id?: string }) { return { ...saveRow(data), - date: new Date(data.date), + createDate: new Date(data.createDate), }; } @@ -153,7 +144,7 @@ export function AutomationVersionTable({ const id = v4(); const row = { ...saveRowMysql(data), id }; await database.insert(mysqlAutomationVersionTable).values(row); - const out = loadRow({ ...row, date: row.date.toISOString() }); + const out = loadRow({ ...row, createDate: row.createDate.toISOString() }); store.set(id, out); return out; }, @@ -197,7 +188,7 @@ export function AutomationVersionTable({ .update(mysqlAutomationVersionTable) .set(row) .where(eq(mysqlAutomationVersionTable.id, id)); - const out = loadRow({ ...row, date: row.date.toISOString() }); + const out = loadRow({ ...row, createDate: row.createDate.toISOString() }); store.set(id, out); return out; }, @@ -209,7 +200,7 @@ export function AutomationVersionTable({ const id = v4(); const row = { ...saveRowMysql(data), id }; await database.insert(postgresAutomationVersionTable).values(row); - const out = loadRow({ ...row, date: row.date.toISOString() }); + const out = loadRow({ ...row, createDate: row.createDate.toISOString() }); store.set(id, out); return out; }, @@ -253,7 +244,7 @@ export function AutomationVersionTable({ .update(postgresAutomationVersionTable) .set(row) .where(eq(postgresAutomationVersionTable.id, id)); - const out = loadRow({ ...row, date: row.date.toISOString() }); + const out = loadRow({ ...row, createDate: row.createDate.toISOString() }); store.set(id, out); return out; }, diff --git a/apps/server/src/database/services/automation.service.mts b/apps/server/src/database/services/automation.service.mts index c4ac934..2b6307b 100644 --- a/apps/server/src/database/services/automation.service.mts +++ b/apps/server/src/database/services/automation.service.mts @@ -1,11 +1,11 @@ -import { TServiceParams } from "@digital-alchemy/core"; +import type { TServiceParams } from "@digital-alchemy/core"; import { eq } from "drizzle-orm"; -import { drizzle } from "drizzle-orm/better-sqlite3"; -import { MySql2Database } from "drizzle-orm/mysql2"; -import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js"; +import type { drizzle } from "drizzle-orm/better-sqlite3"; +import type { MySql2Database } from "drizzle-orm/mysql2"; +import type { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js"; import { v4 } from "uuid"; -import { +import type { AutomationCreateOptions, StoredAutomation, StoredAutomationRow, @@ -26,25 +26,25 @@ export function AutomationTable({ }: TServiceParams) { const store = new Map(); - lifecycle.onBootstrap(function () { + lifecycle.onBootstrap(() => { loadFromDB(); }); // Database-specific implementations const sqlite = { async create(data: AutomationCreateOptions) { - const database = synapse.database.getDatabase() as ReturnType< - typeof drizzle - >; + const database = synapse.database.getDatabase() as ReturnType; const id = v4(); - const row = { id, ...sqlite.save(data) }; + const row = { ...sqlite.save(data), id }; await database.insert(sqliteStoredAutomationTable).values(row); const out = sqlite.load(row); store.set(row.id, out); return out; }, - load(row: Partial & { active_version_id?: string | null }): StoredAutomation { + load( + row: Partial & { active_version_id?: string | null }, + ): StoredAutomation { return { ...row, active: row.active === "true", @@ -54,13 +54,11 @@ export function AutomationTable({ }, async loadFromDB() { - const database = synapse.database.getDatabase() as ReturnType< - typeof drizzle - >; + const database = synapse.database.getDatabase() as ReturnType; store.clear(); - metrics.measure([context, "loadFromDB"], function () { + metrics.measure([context, "loadFromDB"], () => { const rows = database.select().from(sqliteStoredAutomationTable).all(); - rows.forEach(function (row) { + rows.forEach(row => { const loaded = sqlite.load(row); store.set(loaded.id, loaded); }); @@ -68,9 +66,7 @@ export function AutomationTable({ }, async remove(id: string) { - const database = synapse.database.getDatabase() as ReturnType< - typeof drizzle - >; + const database = synapse.database.getDatabase() as ReturnType; store.delete(id); await database .delete(sqliteStoredAutomationTable) @@ -93,24 +89,23 @@ export function AutomationTable({ last_update: now, parent: data.parent, title: data.title, + version: (data as StoredAutomation & { version?: string }).version ?? "", }; }, async update(id: string, data: Partial) { - const database = synapse.database.getDatabase() as ReturnType< - typeof drizzle - >; + const database = synapse.database.getDatabase() as ReturnType; const current = store.get(id); - + if (!current) { // If automation doesn't exist, create it with the provided ID - const row = { id, ...sqlite.save(data as AutomationCreateOptions) }; + const row = { ...sqlite.save(data as AutomationCreateOptions), id }; await database.insert(sqliteStoredAutomationTable).values(row); const out = sqlite.load(row); store.set(id, out); return out; } - + // Otherwise update existing automation const update = sqlite.save({ ...current, ...data }); await database @@ -125,18 +120,18 @@ export function AutomationTable({ const mysql = { async create(data: AutomationCreateOptions) { - const database = synapse.database.getDatabase() as MySql2Database< - Record - >; + const database = synapse.database.getDatabase() as MySql2Database>; const id = v4(); - const row = { id, ...mysql.save(data) }; + const row = { ...mysql.save(data), id }; await database.insert(mysqlStoredAutomationTable).values(row); const out = mysql.load(row); store.set(row.id, out); return out; }, - load(row: Partial & { active_version_id?: string | null }): StoredAutomation { + load( + row: Partial & { active_version_id?: string | null }, + ): StoredAutomation { return { ...row, active: row.active === "true", @@ -146,16 +141,11 @@ export function AutomationTable({ }, async loadFromDB() { - const database = synapse.database.getDatabase() as MySql2Database< - Record - >; + const database = synapse.database.getDatabase() as MySql2Database>; store.clear(); - metrics.measure([context, "loadFromDB"], async function () { - const rows = await database - .select() - .from(mysqlStoredAutomationTable) - .execute(); - rows.forEach(function (row) { + metrics.measure([context, "loadFromDB"], async () => { + const rows = await database.select().from(mysqlStoredAutomationTable).execute(); + rows.forEach(row => { const loaded = mysql.load(row); store.set(loaded.id, loaded); }); @@ -163,9 +153,7 @@ export function AutomationTable({ }, async remove(id: string) { - const database = synapse.database.getDatabase() as MySql2Database< - Record - >; + const database = synapse.database.getDatabase() as MySql2Database>; store.delete(id); await database .delete(mysqlStoredAutomationTable) @@ -190,24 +178,23 @@ export function AutomationTable({ last_update: now, parent: data.parent, title: data.title, + version: (data as StoredAutomation & { version?: string }).version ?? "", }; }, async update(id: string, data: Partial) { - const database = synapse.database.getDatabase() as MySql2Database< - Record - >; + const database = synapse.database.getDatabase() as MySql2Database>; const current = store.get(id); - + if (!current) { // If automation doesn't exist, create it with the provided ID - const row = { id, ...mysql.save(data as AutomationCreateOptions) }; + const row = { ...mysql.save(data as AutomationCreateOptions), id }; await database.insert(mysqlStoredAutomationTable).values(row); const out = mysql.load(row); store.set(id, out); return out; } - + // Otherwise update existing automation const update = mysql.save({ ...current, ...data }); await database @@ -222,18 +209,18 @@ export function AutomationTable({ const postgres = { async create(data: AutomationCreateOptions) { - const database = synapse.database.getDatabase() as ReturnType< - typeof drizzlePostgres - >; + const database = synapse.database.getDatabase() as ReturnType; const id = v4(); - const row = { id, ...postgres.save(data) }; + const row = { ...postgres.save(data), id }; await database.insert(postgresStoredAutomationTable).values(row); const out = postgres.load(row); store.set(row.id, out); return out; }, - load(row: Partial & { active_version_id?: string | null }): StoredAutomation { + load( + row: Partial & { active_version_id?: string | null }, + ): StoredAutomation { return { ...row, active: row.active === "true", @@ -243,16 +230,11 @@ export function AutomationTable({ }, async loadFromDB() { - const database = synapse.database.getDatabase() as ReturnType< - typeof drizzlePostgres - >; + const database = synapse.database.getDatabase() as ReturnType; store.clear(); - metrics.measure([context, "loadFromDB"], async function () { - const rows = await database - .select() - .from(postgresStoredAutomationTable) - .execute(); - rows.forEach(function (row) { + metrics.measure([context, "loadFromDB"], async () => { + const rows = await database.select().from(postgresStoredAutomationTable).execute(); + rows.forEach(row => { const loaded = postgres.load(row); store.set(loaded.id, loaded); }); @@ -260,9 +242,7 @@ export function AutomationTable({ }, async remove(id: string) { - const database = synapse.database.getDatabase() as ReturnType< - typeof drizzlePostgres - >; + const database = synapse.database.getDatabase() as ReturnType; store.delete(id); await database .delete(postgresStoredAutomationTable) @@ -287,24 +267,23 @@ export function AutomationTable({ last_update: now, parent: data.parent, title: data.title, + version: (data as StoredAutomation & { version?: string }).version ?? "", }; }, async update(id: string, data: Partial) { - const database = synapse.database.getDatabase() as ReturnType< - typeof drizzlePostgres - >; + const database = synapse.database.getDatabase() as ReturnType; const current = store.get(id); - + if (!current) { // If automation doesn't exist, create it with the provided ID - const row = { id, ...postgres.save(data as AutomationCreateOptions) }; + const row = { ...postgres.save(data as AutomationCreateOptions), id }; await database.insert(postgresStoredAutomationTable).values(row); const out = postgres.load(row); store.set(id, out); return out; } - + // Otherwise update existing automation const update = postgres.save({ ...current, ...data }); await database diff --git a/apps/server/src/utils/contracts/automation.mts b/apps/server/src/utils/contracts/automation.mts index bf15418..addc4b2 100644 --- a/apps/server/src/utils/contracts/automation.mts +++ b/apps/server/src/utils/contracts/automation.mts @@ -61,10 +61,7 @@ export const AutomationVersion = Type.Object( { automationId: Type.String({ description: "Parent automation UUID" }), body: Type.String({ description: "TypeScript code at this version" }), - date: Type.String({ description: "ISO timestamp of creation" }), - documentation: Type.Optional(Type.String({ description: "Snapshot of automation docs at save time" })), - hasCodeChange: Type.Boolean({ description: "Did body change vs parent?" }), - hasNotesChange: Type.Boolean({ description: "Did documentation change vs parent?" }), + createDate: Type.String({ description: "ISO timestamp of creation" }), id: Type.String({ description: "UUID" }), isActive: Type.Boolean({ description: "Is this the active running version?" }), isDraft: Type.Boolean({ description: "Is this an unsaved draft?" }),