Skip to content

Commit f96f90b

Browse files
refactor(desktop): store organizations as markdown files with YAML frontmatter
- Change organization persister from JSON to markdown files with frontmatter - Store each organization as organizations/<UUID>.md - Add auto-migration from organizations.json on first load - Add cleanup of orphan files when records are deleted - Follow the same pattern as the human persister from PR #2791 Organization schema fields (user_id, created_at, name) are stored in YAML frontmatter. The markdown body is always empty since organizations have no memo field. Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
1 parent ad094c9 commit f96f90b

File tree

6 files changed

+553
-49
lines changed

6 files changed

+553
-49
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas";
2+
3+
import type { FrontmatterInput, JsonValue } from "@hypr/plugin-export";
4+
import type { OrganizationStorage } from "@hypr/store";
5+
6+
import type { CollectorResult, TablesContent } from "../utils";
7+
import { getOrganizationDir, getOrganizationFilePath } from "./utils";
8+
9+
export interface OrganizationCollectorResult extends CollectorResult {
10+
validOrgIds: Set<string>;
11+
}
12+
13+
type OrganizationsTable = Record<string, OrganizationStorage>;
14+
15+
export function collectOrganizationWriteOps<Schemas extends OptionalSchemas>(
16+
_store: MergeableStore<Schemas>,
17+
tables: TablesContent,
18+
dataDir: string,
19+
): OrganizationCollectorResult {
20+
const dirs = new Set<string>();
21+
const operations: CollectorResult["operations"] = [];
22+
const validOrgIds = new Set<string>();
23+
24+
const organizationsDir = getOrganizationDir(dataDir);
25+
dirs.add(organizationsDir);
26+
27+
const organizations =
28+
(tables as { organizations?: OrganizationsTable }).organizations ?? {};
29+
30+
const frontmatterItems: [FrontmatterInput, string][] = [];
31+
32+
for (const [orgId, org] of Object.entries(organizations)) {
33+
validOrgIds.add(orgId);
34+
35+
const frontmatter: Record<string, JsonValue> = {
36+
created_at: org.created_at ?? "",
37+
name: org.name ?? "",
38+
user_id: org.user_id ?? "",
39+
};
40+
41+
const body = "";
42+
const filePath = getOrganizationFilePath(dataDir, orgId);
43+
44+
frontmatterItems.push([{ frontmatter, content: body }, filePath]);
45+
}
46+
47+
if (frontmatterItems.length > 0) {
48+
operations.push({
49+
type: "frontmatter-batch",
50+
items: frontmatterItems,
51+
});
52+
}
53+
54+
return { dirs, operations, validOrgIds };
55+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { readDir, readTextFile, remove } from "@tauri-apps/plugin-fs";
2+
3+
import type { OrganizationStorage } from "@hypr/store";
4+
5+
import { isFileNotFoundError, isUUID } from "../utils";
6+
import {
7+
getOrganizationDir,
8+
getOrganizationFilePath,
9+
parseMarkdownWithFrontmatter,
10+
} from "./utils";
11+
12+
export async function loadAllOrganizations(
13+
dataDir: string,
14+
): Promise<Record<string, OrganizationStorage>> {
15+
const result: Record<string, OrganizationStorage> = {};
16+
const organizationsDir = getOrganizationDir(dataDir);
17+
18+
let entries: { name: string; isDirectory: boolean }[];
19+
try {
20+
entries = await readDir(organizationsDir);
21+
} catch (error) {
22+
if (isFileNotFoundError(error)) {
23+
return result;
24+
}
25+
throw error;
26+
}
27+
28+
for (const entry of entries) {
29+
if (entry.isDirectory) continue;
30+
if (!entry.name.endsWith(".md")) continue;
31+
32+
const orgId = entry.name.replace(/\.md$/, "");
33+
if (!isUUID(orgId)) {
34+
console.warn(
35+
`[OrganizationPersister] Skipping non-UUID file: ${entry.name}`,
36+
);
37+
continue;
38+
}
39+
40+
try {
41+
const filePath = getOrganizationFilePath(dataDir, orgId);
42+
const content = await readTextFile(filePath);
43+
const { frontmatter } = await parseMarkdownWithFrontmatter(content);
44+
45+
result[orgId] = {
46+
user_id: String(frontmatter.user_id ?? ""),
47+
created_at: String(frontmatter.created_at ?? ""),
48+
name: String(frontmatter.name ?? ""),
49+
};
50+
} catch (error) {
51+
console.error(
52+
`[OrganizationPersister] Failed to load organization ${orgId}:`,
53+
error,
54+
);
55+
continue;
56+
}
57+
}
58+
59+
return result;
60+
}
61+
62+
export async function cleanupOrphanOrganizationFiles(
63+
dataDir: string,
64+
validOrgIds: Set<string>,
65+
): Promise<void> {
66+
const organizationsDir = getOrganizationDir(dataDir);
67+
68+
let entries: { name: string; isDirectory: boolean }[];
69+
try {
70+
entries = await readDir(organizationsDir);
71+
} catch (error) {
72+
if (isFileNotFoundError(error)) {
73+
return;
74+
}
75+
throw error;
76+
}
77+
78+
for (const entry of entries) {
79+
if (entry.isDirectory) continue;
80+
if (!entry.name.endsWith(".md")) continue;
81+
82+
const orgId = entry.name.replace(/\.md$/, "");
83+
if (!isUUID(orgId)) continue;
84+
85+
if (!validOrgIds.has(orgId)) {
86+
try {
87+
const filePath = getOrganizationFilePath(dataDir, orgId);
88+
await remove(filePath);
89+
} catch (error) {
90+
if (!isFileNotFoundError(error)) {
91+
console.error(
92+
`[OrganizationPersister] Failed to remove orphan file ${entry.name}:`,
93+
error,
94+
);
95+
}
96+
}
97+
}
98+
}
99+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { sep } from "@tauri-apps/api/path";
2+
import { exists, mkdir, readTextFile, remove } from "@tauri-apps/plugin-fs";
3+
4+
import {
5+
commands as exportCommands,
6+
type FrontmatterInput,
7+
type JsonValue,
8+
} from "@hypr/plugin-export";
9+
import type { OrganizationStorage } from "@hypr/store";
10+
11+
import { isFileNotFoundError } from "../utils";
12+
import { getOrganizationDir, getOrganizationFilePath } from "./utils";
13+
14+
export async function migrateOrganizationsJsonIfNeeded(
15+
dataDir: string,
16+
): Promise<void> {
17+
const organizationsJsonPath = [dataDir, "organizations.json"].join(sep());
18+
const organizationsDir = getOrganizationDir(dataDir);
19+
20+
const jsonExists = await exists(organizationsJsonPath);
21+
if (!jsonExists) {
22+
return;
23+
}
24+
25+
const dirExists = await exists(organizationsDir);
26+
if (dirExists) {
27+
return;
28+
}
29+
30+
console.log(
31+
"[OrganizationPersister] Migrating from organizations.json to organizations/*.md",
32+
);
33+
34+
try {
35+
const content = await readTextFile(organizationsJsonPath);
36+
const organizations = JSON.parse(content) as Record<
37+
string,
38+
OrganizationStorage
39+
>;
40+
41+
await mkdir(organizationsDir, { recursive: true });
42+
43+
const batchItems: [FrontmatterInput, string][] = [];
44+
45+
for (const [orgId, org] of Object.entries(organizations)) {
46+
const frontmatter: Record<string, JsonValue> = {
47+
created_at: org.created_at ?? "",
48+
name: org.name ?? "",
49+
user_id: org.user_id ?? "",
50+
};
51+
52+
const body = "";
53+
const filePath = getOrganizationFilePath(dataDir, orgId);
54+
55+
batchItems.push([{ frontmatter, content: body }, filePath]);
56+
}
57+
58+
if (batchItems.length > 0) {
59+
const result = await exportCommands.exportFrontmatterBatch(batchItems);
60+
if (result.status === "error") {
61+
throw new Error(
62+
`Failed to export migrated organizations: ${result.error}`,
63+
);
64+
}
65+
}
66+
67+
await remove(organizationsJsonPath);
68+
69+
console.log(
70+
`[OrganizationPersister] Migration complete: ${Object.keys(organizations).length} organizations migrated`,
71+
);
72+
} catch (error) {
73+
if (!isFileNotFoundError(error)) {
74+
console.error("[OrganizationPersister] Migration failed:", error);
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)