| ... | ... | @@ -16,6 +16,7 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; |
| 16 | 16 | import * as Effect from "effect/Effect"; |
| 17 | 17 | import * as Exit from "effect/Exit"; |
| 18 | 18 | import * as FileSystem from "effect/FileSystem"; |
| 19 | import * as Option from "effect/Option"; |
| 19 | 20 | import * as Path from "effect/Path"; |
| 20 | 21 | import type * as PlatformError from "effect/PlatformError"; |
| 21 | 22 | import * as SqlClient from "effect/sql/SqlClient"; |
| ... | ... | @@ -93,12 +94,12 @@ export interface OpenCodeUsageReadResult { |
| 93 | 94 | const isNotFound = (cause: PlatformError.PlatformError) => cause.reason._tag === "NotFound"; |
| 94 | 95 | const MESSAGE_PAGE_SIZE = 64; |
| 95 | 96 | |
| 96 | | /** A regular file or directory, never a symlink to one. */ |
| 97 | | const entryType = Effect.fn("entryType")(function* (path: string) { |
| 97 | /** Stat of a regular file or directory; a symlink reports as itself and is never followed. */ |
| 98 | const entryInfo = Effect.fn("entryInfo")(function* (path: string) { |
| 98 | 99 | const fileSystem = yield* FileSystem.FileSystem; |
| 99 | 100 | const link = yield* Effect.exit(fileSystem.readLink(path)); |
| 100 | | if (Exit.isSuccess(link)) return "SymbolicLink" as const; |
| 101 | | return (yield* fileSystem.stat(path)).type; |
| 101 | if (Exit.isSuccess(link)) return { type: "SymbolicLink" as const, mtime: Option.none<Date>() }; |
| 102 | return yield* fileSystem.stat(path); |
| 102 | 103 | }); |
| 103 | 104 | |
| 104 | 105 | /** Reads current SQLite and pre-migration JSON stores without modifying either. */ |
| ... | ... | @@ -127,8 +128,8 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( |
| 127 | 128 | names.filter((name) => /^opencode(?:-[a-zA-Z0-9_-]+)?\.db$/.test(name)), |
| 128 | 129 | // One database removed between listing and stat must not hide the rest. |
| 129 | 130 | (name) => |
| 130 | | entryType(path.join(root, name)).pipe( |
| 131 | | Effect.map((type) => type === "File"), |
| 131 | entryInfo(path.join(root, name)).pipe( |
| 132 | Effect.map((info) => info.type === "File"), |
| 132 | 133 | Effect.catchTags({ |
| 133 | 134 | PlatformError: (cause) => |
| 134 | 135 | isNotFound(cause) ? Effect.succeed(false) : Effect.fail(cause), |
| ... | ... | @@ -235,18 +236,21 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( |
| 235 | 236 | yield* Effect.gen(function* () { |
| 236 | 237 | for (const name of yield* fileSystem.readDirectory(directory)) { |
| 237 | 238 | const entry = path.join(directory, name); |
| 238 | | const type = yield* entryType(entry).pipe( |
| 239 | const info = yield* entryInfo(entry).pipe( |
| 239 | 240 | Effect.catchTags({ |
| 240 | 241 | PlatformError: (cause) => |
| 241 | 242 | isNotFound(cause) ? Effect.succeed(null) : Effect.fail(cause), |
| 242 | 243 | }), |
| 243 | 244 | ); |
| 244 | | if (type === "Directory") { |
| 245 | if (info?.type === "Directory") { |
| 245 | 246 | directories.push(entry); |
| 246 | | } else if (type === "File" && name.endsWith(".json")) { |
| 247 | } else if (info?.type === "File" && name.endsWith(".json")) { |
| 247 | 248 | found = true; |
| 248 | 249 | const id = name.slice(0, -5); |
| 249 | 250 | if (seen.has(`opencode:${id}`)) continue; |
| 251 | // A message cannot be created after its file was last written, so a |
| 252 | // file untouched since the window opened holds nothing in range. |
| 253 | if (Option.exists(info.mtime, (mtime) => mtime.getTime() < sinceMs)) continue; |
| 250 | 254 | const file = { path: entry, records: [] as UsageRecord[] }; |
| 251 | 255 | files.push(file); |
| 252 | 256 | yield* fileSystem.readFileString(entry).pipe( |