@@ -5,16 +5,19 @@ import crypto from "node:crypto";
55import matter from "gray-matter" ;
66import type Database from "better-sqlite3" ;
77
8- type SyncCounts = {
8+ export type SyncCounts = {
99 rootDir : string ;
1010 locales : string [ ] ;
1111 scanned : number ;
1212 upserted : number ;
1313 skipped : number ;
1414 revisionsInserted : number ;
1515 pointersUpdated : number ;
16+ pointersSkippedProtected : number ;
17+ pointersForced : number ;
1618 emptyBodySkipped : number ;
1719 errors : Array < { file : string ; error : string } > ;
20+ protected : Array < { slug : string ; locale : string ; reason : string } > ;
1821} ;
1922
2023/* ===========================================================
@@ -83,7 +86,11 @@ function jsonString(v: any, fallback: any): string {
8386 }
8487}
8588
86- export function syncLabNotesFromFs ( db : Database . Database ) : SyncCounts {
89+ export function syncLabNotesFromFs (
90+ db : Database . Database ,
91+ opts ?: { force ?: boolean }
92+ ) : SyncCounts {
93+ const force = Boolean ( opts ?. force ) ;
8794 const rootDir = String ( process . env . LABNOTES_DIR || "" ) . trim ( ) ;
8895 if ( ! rootDir ) throw new Error ( "LABNOTES_DIR is not set" ) ;
8996 if ( ! fs . existsSync ( rootDir ) ) throw new Error ( `LABNOTES_DIR not found: ${ rootDir } ` ) ;
@@ -103,8 +110,11 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts {
103110 skipped : 0 ,
104111 revisionsInserted : 0 ,
105112 pointersUpdated : 0 ,
113+ pointersSkippedProtected : 0 ,
114+ pointersForced : 0 ,
106115 emptyBodySkipped : 0 ,
107116 errors : [ ] ,
117+ protected : [ ] ,
108118 } ;
109119
110120 // -----------------------------
@@ -167,10 +177,18 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts {
167177 ` ) ;
168178
169179 const selectLatestRevision = db . prepare ( `
170- SELECT id, revision_num, content_hash, length(content_markdown) AS md_len
171- FROM lab_note_revisions
172- WHERE note_id = ?
173- ORDER BY revision_num DESC
180+ SELECT id, revision_num, content_hash, length(content_markdown) AS md_len, source
181+ FROM lab_note_revisions
182+ WHERE note_id = ?
183+ ORDER BY revision_num DESC
184+ LIMIT 1
185+ ` ) ;
186+
187+ const selectCurrentRevisionSource = db . prepare ( `
188+ SELECT r.source AS source
189+ FROM lab_notes n
190+ LEFT JOIN lab_note_revisions r ON r.id = n.current_revision_id
191+ WHERE n.id = ?
174192 LIMIT 1
175193 ` ) ;
176194
@@ -287,12 +305,20 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts {
287305 const hash = sha256Hex ( markdown ) ;
288306
289307 const latest = selectLatestRevision . get ( noteRow . id ) as
290- | { id : string ; revision_num : number ; content_hash : string ; md_len : number }
308+ | { id : string ; revision_num : number ; content_hash : string ; md_len : number ; source : string | null }
291309 | undefined ;
292310
293311 // 3) Idempotency: if the latest revision already matches this body, do nothing
312+ // 3) Idempotency: if the latest revision already matches this body, usually do nothing
294313 if ( latest && latest . content_hash === hash && ( latest . md_len ?? 0 ) > 0 ) {
295- counts . skipped += 1 ;
314+ // But if we're forcing, we may still need to advance pointers to the latest import revision
315+ if ( force && latest . source === "import" ) {
316+ updatePointers . run ( latest . id , latest . id , noteRow . id ) ;
317+ counts . pointersUpdated += 1 ;
318+ counts . pointersForced += 1 ;
319+ } else {
320+ counts . skipped += 1 ;
321+ }
296322 return ;
297323 }
298324
@@ -350,9 +376,43 @@ export function syncLabNotesFromFs(db: Database.Database): SyncCounts {
350376 } ) ;
351377 counts . revisionsInserted += 1 ;
352378
353- // 5) Advance pointers ONLY to a non-empty revision (this one is guaranteed non-empty)
354- updatePointers . run ( newRevId , newRevId , noteRow . id ) ;
355- counts . pointersUpdated += 1 ;
379+ // 5) Advance pointers ONLY if safe (and non-empty revision is already guaranteed)
380+ const curSourceRow = selectCurrentRevisionSource . get ( noteRow . id ) as
381+ | { source ?: string | null }
382+ | undefined ;
383+
384+ const curSource = curSourceRow ?. source ?? null ;
385+
386+ // Safe-to-advance rules:
387+ // - force=true: always advance (explicit override)
388+ // - no current revision: new note, safe
389+ // - current source is 'import': FS already owns the current draft
390+ const canAdvance =
391+ force ||
392+ ! noteRow . current_revision_id ||
393+ curSource === "import" ;
394+
395+ if ( canAdvance ) {
396+ updatePointers . run ( newRevId , newRevId , noteRow . id ) ;
397+ counts . pointersUpdated += 1 ;
398+
399+ if ( force && noteRow . current_revision_id && curSource !== "import" ) {
400+ counts . pointersForced += 1 ;
401+ }
402+ } else {
403+ // Protected admin draft: we still inserted the import revision, but we don't switch pointers
404+ counts . pointersSkippedProtected += 1 ;
405+
406+ // Optional: record protected items for UI/debug (cap it)
407+ if ( counts . protected . length < 50 ) {
408+ counts . protected . push ( {
409+ slug,
410+ locale,
411+ reason : `protected admin draft (current source=${ curSource } )` ,
412+ } ) ;
413+ }
414+ }
415+
356416 } catch ( e : any ) {
357417 counts . errors . push ( { file : filePath , error : e ?. message ?? String ( e ) } ) ;
358418 }
0 commit comments