Skip to content
Merged
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
55 changes: 55 additions & 0 deletions apps/desktop/src/store/tinybase/persister/organization/collect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas";

import type { FrontmatterInput, JsonValue } from "@hypr/plugin-export";
import type { OrganizationStorage } from "@hypr/store";

import type { CollectorResult, TablesContent } from "../utils";
import { getOrganizationDir, getOrganizationFilePath } from "./utils";

export interface OrganizationCollectorResult extends CollectorResult {
validOrgIds: Set<string>;
}

type OrganizationsTable = Record<string, OrganizationStorage>;

export function collectOrganizationWriteOps<Schemas extends OptionalSchemas>(
_store: MergeableStore<Schemas>,
tables: TablesContent,
dataDir: string,
): OrganizationCollectorResult {
const dirs = new Set<string>();
const operations: CollectorResult["operations"] = [];
const validOrgIds = new Set<string>();

const organizationsDir = getOrganizationDir(dataDir);
dirs.add(organizationsDir);

const organizations =
(tables as { organizations?: OrganizationsTable }).organizations ?? {};

const frontmatterItems: [FrontmatterInput, string][] = [];

for (const [orgId, org] of Object.entries(organizations)) {
validOrgIds.add(orgId);

const frontmatter: Record<string, JsonValue> = {
created_at: org.created_at ?? "",
name: org.name ?? "",
user_id: org.user_id ?? "",
};

const body = "";
const filePath = getOrganizationFilePath(dataDir, orgId);

frontmatterItems.push([{ frontmatter, content: body }, filePath]);
}

if (frontmatterItems.length > 0) {
operations.push({
type: "frontmatter-batch",
items: frontmatterItems,
});
}

return { dirs, operations, validOrgIds };
}
99 changes: 99 additions & 0 deletions apps/desktop/src/store/tinybase/persister/organization/load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { readDir, readTextFile, remove } from "@tauri-apps/plugin-fs";

import type { OrganizationStorage } from "@hypr/store";

import { isFileNotFoundError, isUUID } from "../utils";
import {
getOrganizationDir,
getOrganizationFilePath,
parseMarkdownWithFrontmatter,
} from "./utils";

export async function loadAllOrganizations(
dataDir: string,
): Promise<Record<string, OrganizationStorage>> {
const result: Record<string, OrganizationStorage> = {};
const organizationsDir = getOrganizationDir(dataDir);

let entries: { name: string; isDirectory: boolean }[];
try {
entries = await readDir(organizationsDir);
} catch (error) {
if (isFileNotFoundError(error)) {
return result;
}
throw error;
}

for (const entry of entries) {
if (entry.isDirectory) continue;
if (!entry.name.endsWith(".md")) continue;

const orgId = entry.name.replace(/\.md$/, "");
if (!isUUID(orgId)) {
console.warn(
`[OrganizationPersister] Skipping non-UUID file: ${entry.name}`,
);
continue;
}

try {
const filePath = getOrganizationFilePath(dataDir, orgId);
const content = await readTextFile(filePath);
const { frontmatter } = await parseMarkdownWithFrontmatter(content);

result[orgId] = {
user_id: String(frontmatter.user_id ?? ""),
created_at: String(frontmatter.created_at ?? ""),
name: String(frontmatter.name ?? ""),
};
} catch (error) {
console.error(
`[OrganizationPersister] Failed to load organization ${orgId}:`,
error,
);
continue;
}
}

return result;
}

export async function cleanupOrphanOrganizationFiles(
dataDir: string,
validOrgIds: Set<string>,
): Promise<void> {
const organizationsDir = getOrganizationDir(dataDir);

let entries: { name: string; isDirectory: boolean }[];
try {
entries = await readDir(organizationsDir);
} catch (error) {
if (isFileNotFoundError(error)) {
return;
}
throw error;
}

for (const entry of entries) {
if (entry.isDirectory) continue;
if (!entry.name.endsWith(".md")) continue;

const orgId = entry.name.replace(/\.md$/, "");
if (!isUUID(orgId)) continue;

if (!validOrgIds.has(orgId)) {
try {
const filePath = getOrganizationFilePath(dataDir, orgId);
await remove(filePath);
} catch (error) {
if (!isFileNotFoundError(error)) {
console.error(
`[OrganizationPersister] Failed to remove orphan file ${entry.name}:`,
error,
);
}
}
}
}
}
77 changes: 77 additions & 0 deletions apps/desktop/src/store/tinybase/persister/organization/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { sep } from "@tauri-apps/api/path";
import { exists, mkdir, readTextFile, remove } from "@tauri-apps/plugin-fs";

import {
commands as exportCommands,
type FrontmatterInput,
type JsonValue,
} from "@hypr/plugin-export";
import type { OrganizationStorage } from "@hypr/store";

import { isFileNotFoundError } from "../utils";
import { getOrganizationDir, getOrganizationFilePath } from "./utils";

export async function migrateOrganizationsJsonIfNeeded(
dataDir: string,
): Promise<void> {
const organizationsJsonPath = [dataDir, "organizations.json"].join(sep());
const organizationsDir = getOrganizationDir(dataDir);

const jsonExists = await exists(organizationsJsonPath);
if (!jsonExists) {
return;
}

const dirExists = await exists(organizationsDir);
if (dirExists) {
return;
}
Comment on lines +25 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: Potential data loss during migration

The migration skips if the organizations directory exists, even if it's empty or incomplete. This prevents re-migration if a previous migration failed partway through.

Scenario that breaks:

  1. User has organizations.json with data
  2. Migration starts and creates organizations directory
  3. Migration fails before completing (crash, network issue, etc.)
  4. On next startup, migration is skipped because directory exists
  5. Data from organizations.json is never migrated

Fix:
Check if the directory is non-empty instead of just checking existence:

const dirExists = await exists(organizationsDir);
if (dirExists) {
  // Check if directory has any .md files before skipping
  const entries = await readDir(organizationsDir);
  const hasMdFiles = entries.some(e => !e.isDirectory && e.name.endsWith('.md'));
  if (hasMdFiles) {
    return; // Only skip if there are actual migrated files
  }
}
Suggested change
const dirExists = await exists(organizationsDir);
if (dirExists) {
return;
}
const dirExists = await exists(organizationsDir);
if (dirExists) {
// Check if directory has any .md files before skipping
const entries = await readDir(organizationsDir);
const hasMdFiles = entries.some(e => !e.isDirectory && e.name.endsWith('.md'));
if (hasMdFiles) {
return; // Only skip if there are actual migrated files
}
}

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


console.log(
"[OrganizationPersister] Migrating from organizations.json to organizations/*.md",
);

try {
const content = await readTextFile(organizationsJsonPath);
const organizations = JSON.parse(content) as Record<
string,
OrganizationStorage
>;

await mkdir(organizationsDir, { recursive: true });

const batchItems: [FrontmatterInput, string][] = [];

for (const [orgId, org] of Object.entries(organizations)) {
const frontmatter: Record<string, JsonValue> = {
created_at: org.created_at ?? "",
name: org.name ?? "",
user_id: org.user_id ?? "",
};

const body = "";
const filePath = getOrganizationFilePath(dataDir, orgId);

batchItems.push([{ frontmatter, content: body }, filePath]);
}

if (batchItems.length > 0) {
const result = await exportCommands.exportFrontmatterBatch(batchItems);
if (result.status === "error") {
throw new Error(
`Failed to export migrated organizations: ${result.error}`,
);
}
}

await remove(organizationsJsonPath);

console.log(
`[OrganizationPersister] Migration complete: ${Object.keys(organizations).length} organizations migrated`,
);
} catch (error) {
if (!isFileNotFoundError(error)) {
console.error("[OrganizationPersister] Migration failed:", error);
}
}
}
Loading
Loading