Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 157 additions & 8 deletions apps/client/src/store/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -16,20 +18,30 @@ type ClientOnlyState = {
* Has the automation been edited since last save?
*/
_isEdited: boolean
/**
* Version history for this automation, keyed by version ID.
*/
_versions: Map<string, AutomationVersion>
}

type RequiredServerStoredAutomation = Required<ServerStoredAutomation>

type AutomationType = RequiredServerStoredAutomation & ClientOnlyState

type AutomationUpdateOptions = ServerAutomationUpdateOptions &
Partial<ClientOnlyState>

export const emptyAutomation: AutomationType = {
_isEdited: false,
_versions: new Map(),
/**
* Is this automation turned on and running?
*/
active: false,
/**
* ID of the currently active version.
*/
activeVersionId: "",
/**
* What HASS area is this automation associated with?
*/
Expand All @@ -50,10 +62,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.
*/
Expand All @@ -75,10 +83,6 @@ export const emptyAutomation: AutomationType = {
* Title of the automation.
*/
title: "",
/**
* Not yet used
*/
version: "",
}

/**
Expand Down Expand Up @@ -161,6 +165,150 @@ const automationFactory = createFactory<AutomationType, Record<string, never>>(
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<void> {
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<AutomationVersion | undefined> {
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<AutomationVersion | undefined> {
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<AutomationVersion | undefined> {
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<void> {
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<ServerAutomationCreateOptions> = emptyAutomation,
Expand All @@ -170,6 +318,7 @@ export const createLocalAutomation = (
{
id: uuid(),
...initialData,
_versions: proxyMap<string, AutomationVersion>([]),
createDate: getNowISO(),
lastUpdate: getNowISO(),
},
Expand Down
50 changes: 50 additions & 0 deletions apps/client/src/store/automationVersion.ts
Original file line number Diff line number Diff line change
@@ -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<ServerAutomationVersion>(
emptyVersion,
).actions({
async update(updates: AutomationVersionUpdateOptions): Promise<void> {
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<typeof versionFactory>
21 changes: 14 additions & 7 deletions apps/client/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>[] = []

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)
})
}

Expand Down Expand Up @@ -146,3 +152,4 @@ async function initializeApp() {
initializeApp()

export * from "./automation"
export type { AutomationVersion } from "./automationVersion"
14 changes: 13 additions & 1 deletion apps/server/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mkdirSync } from "fs";
import { defineConfig } from "drizzle-kit";

// Get database type from environment or default to sqlite
Expand All @@ -10,14 +11,25 @@ const baseConfig = {
strict: true,
};

// Resolve the SQLite database URL and ensure its directory exists
// Match the default used by @digital-alchemy/synapse: file:<cwd>/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: {
...baseConfig,
dialect: "sqlite" as const,
out: "./migrations/sqlite",
dbCredentials: {
url: process.env.DATABASE_URL || "file:/data/synapse_storage.db",
url: sqliteUrl,
},
},
postgresql: {
Expand Down
48 changes: 48 additions & 0 deletions apps/server/migrations/mysql/0001_automation_versioning.sql
Original file line number Diff line number Diff line change
@@ -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`;
Loading
Loading