pi-para 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/state.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import Database from "better-sqlite3";
8
8
  import { createHash } from "node:crypto";
9
- import { readFileSync } from "node:fs";
9
+ import { readFileSync, statSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
 
12
12
  export interface PageSummary {
@@ -62,17 +62,48 @@ export class StateDB {
62
62
  updated_at TEXT NOT NULL
63
63
  );
64
64
  `);
65
+ // Stat fast path for isProcessed(): when size+mtime are unchanged since
66
+ // the recorded hash, skip re-hashing the whole session file. Critical for
67
+ // startup catch-up scans over registries with very large session JSONLs.
68
+ try {
69
+ this.db.exec("ALTER TABLE processed_sessions ADD COLUMN file_size INTEGER");
70
+ } catch { /* column exists */ }
71
+ try {
72
+ this.db.exec("ALTER TABLE processed_sessions ADD COLUMN file_mtime_ms INTEGER");
73
+ } catch { /* column exists */ }
65
74
  }
66
75
 
67
- /** Check if a session file has already been processed with the same content. */
76
+ /**
77
+ * Check if a session file has already been processed with the same content.
78
+ * Uses a stat (size+mtime) fast path before falling back to a full content
79
+ * hash, so registry-wide scans stay cheap even with multi-hundred-MB
80
+ * session files. A hash match under a changed stat refreshes the cache.
81
+ */
68
82
  isProcessed(sessionPath: string): boolean {
69
83
  const row = this.db
70
- .prepare("SELECT content_hash FROM processed_sessions WHERE session_path = ?")
71
- .get(sessionPath) as { content_hash: string } | undefined;
84
+ .prepare("SELECT content_hash, file_size, file_mtime_ms FROM processed_sessions WHERE session_path = ?")
85
+ .get(sessionPath) as { content_hash: string; file_size: number | null; file_mtime_ms: number | null } | undefined;
72
86
  if (!row) return false;
73
87
 
88
+ let stat: { size: number; mtimeMs: number };
89
+ try {
90
+ stat = statSync(sessionPath);
91
+ } catch {
92
+ return false;
93
+ }
94
+ // mtimeMs is a float and loses sub-millisecond precision across
95
+ // utimes/copy round-trips; compare with a small tolerance. Size is exact.
96
+ if (row.file_size !== null && row.file_mtime_ms !== null &&
97
+ row.file_size === stat.size && Math.abs(row.file_mtime_ms - stat.mtimeMs) <= 2) {
98
+ return true;
99
+ }
100
+
74
101
  const currentHash = hashFile(sessionPath);
75
- return row.content_hash === currentHash;
102
+ if (row.content_hash !== currentHash) return false;
103
+ this.db
104
+ .prepare("UPDATE processed_sessions SET file_size = ?, file_mtime_ms = ? WHERE session_path = ?")
105
+ .run(stat.size, Math.floor(stat.mtimeMs), sessionPath);
106
+ return true;
76
107
  }
77
108
 
78
109
  /** Get the stored hash for a session (or null if not processed). */
@@ -91,11 +122,12 @@ export class StateDB {
91
122
  pagesUpdated: string[],
92
123
  ): void {
93
124
  const hash = hashFile(sessionPath);
125
+ const stat = statFile(sessionPath);
94
126
  this.db
95
127
  .prepare(`
96
128
  INSERT OR REPLACE INTO processed_sessions
97
- (session_path, content_hash, processed_at, scope, pages_created, pages_updated, error)
98
- VALUES (?, ?, ?, ?, ?, ?, NULL)
129
+ (session_path, content_hash, processed_at, scope, pages_created, pages_updated, error, file_size, file_mtime_ms)
130
+ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)
99
131
  `)
100
132
  .run(
101
133
  sessionPath,
@@ -104,19 +136,23 @@ export class StateDB {
104
136
  scope,
105
137
  JSON.stringify(pagesCreated),
106
138
  JSON.stringify(pagesUpdated),
139
+ stat?.size ?? null,
140
+ stat ? Math.floor(stat.mtimeMs) : null,
107
141
  );
108
142
  }
109
143
 
110
144
  /** Record a failed processing attempt. */
111
145
  recordFailure(sessionPath: string, scope: string, error: string): void {
112
146
  const hash = hashFile(sessionPath);
147
+ const stat = statFile(sessionPath);
113
148
  this.db
114
149
  .prepare(`
115
150
  INSERT OR REPLACE INTO processed_sessions
116
- (session_path, content_hash, processed_at, scope, pages_created, pages_updated, error)
117
- VALUES (?, ?, ?, ?, '[]', '[]', ?)
151
+ (session_path, content_hash, processed_at, scope, pages_created, pages_updated, error, file_size, file_mtime_ms)
152
+ VALUES (?, ?, ?, ?, '[]', '[]', ?, ?, ?)
118
153
  `)
119
- .run(sessionPath, hash, new Date().toISOString(), scope, error);
154
+ .run(sessionPath, hash, new Date().toISOString(), scope, error,
155
+ stat?.size ?? null, stat ? Math.floor(stat.mtimeMs) : null);
120
156
  }
121
157
 
122
158
  /** Get all failed sessions for retry. */
@@ -241,6 +277,14 @@ export class StateDB {
241
277
  }
242
278
  }
243
279
 
280
+ function statFile(filePath: string): { size: number; mtimeMs: number } | null {
281
+ try {
282
+ return statSync(filePath);
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
244
288
  function hashFile(filePath: string): string {
245
289
  try {
246
290
  const content = readFileSync(filePath);