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