docs/plans/2026-10-04-workspace-index.md
1387 lines · 57081 bytes
9 symbols in this file
1# Workspace and Index Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** A SQLite index of every org file under the user's folders, kept in line with the files by reconciliation scans that FSEvents batches trigger, with full-text search, ID lookup and an overlay for unsaved buffers.
6
7**Architecture:** Two targets. `OrgIndex` turns a file's bytes into a `FileRecord` (pure, no database), stores records with GRDB, and answers queries; every query takes an overlay of records for unsaved buffers that replace those files' rows. `OrgWorkspace` classifies and scans folders, decides agenda membership, reconciles a root (or a batch of changed paths) against the index with rename detection, and wraps FSEvents. File events are only hints: reconciliation compares size, mtime, hash and settings version, and is safe to run at any time.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, GRDB 7.11 (SQLite with FTS5), CryptoKit, CoreServices (FSEvents, macOS only).
10
11**Spec:** `docs/design.md`, "Workspace, storage and index".
12
13## Global Constraints
14
15- The index is a cache; nothing exists only in it.
16- Syncthing conflict copies are listed but never indexed for search, agenda or IDs.
17- Index rows record the settings version; a new version reindexes.
18- Query results carry the content hash their offsets refer to.
19- Gates (design, "Phases"): full rebuild of 10,000 files under 30 s; reconciliation with no changes under 2 s.
20
21## Out of scope
22
23Agenda queries (phase 3), security-scoped bookmark creation in a sandbox (the Mac app is unsandboxed; iOS adds it), `NSFilePresenter`/`NSMetadataQuery` watching on iOS, and conflict UI.
24
25## File structure
26
27| File | Responsibility |
28| --- | --- |
29| `Package.swift` | `OrgIndex` (GRDB) and `OrgWorkspace` targets |
30| `Sources/OrgIndex/FileRecord.swift` | `FileRecord` and its row types, built from bytes |
31| `Sources/OrgIndex/IndexStore.swift` | Schema, transactional writes, search, IDs, overlay |
32| `Sources/OrgWorkspace/Discovery.swift` | `WorkspaceRoot`, file classification, scanning, `AgendaScope` |
33| `Sources/OrgWorkspace/Reconciler.swift` | Index reconciliation with touches, moves and removals |
34| `Sources/OrgWorkspace/FSEventsWatcher.swift` | File-level FSEvents with rescan events |
35
36---
37
38### Task 1: File records
39
40**Files:**
41- Modify: `Package.swift` (add `OrgIndex` and GRDB)
42- Create: `Sources/OrgIndex/FileRecord.swift`
43- Test: `Tests/OrgIndexTests/FileRecordTests.swift`
44
45**Interfaces:**
46- Consumes: `OrgParser`, `DocumentModel`, `SourceText`, `Timestamp`.
47- Produces: `FileKind`, `IndexSettings(version:org:semantic:)`, `FileRecord(path:root:kind:bytes:mtime:settings:)`, `FileRecord.hash(_:)`, `HeadingRecord`, `TagRow`, `PropertyRow`, `TimestampRow`, `ClockRow`, `LinkRow`.
48
49- [ ] **Step 1: Add the target**
50
51```swift
52// swift-tools-version: 6.2
53import PackageDescription
54
55let package = Package(
56 name: "Orgstar",
57 platforms: [.macOS(.v26), .iOS(.v26)],
58 products: [
59 .library(name: "OrgCore", targets: ["OrgCore"]),
60 .library(name: "OrgDocument", targets: ["OrgDocument"]),
61 .library(name: "OrgIndex", targets: ["OrgIndex"])
62 ],
63 dependencies: [
64 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1")
65 ],
66 targets: [
67 .target(name: "OrgCore"),
68 .target(name: "OrgDocument", dependencies: ["OrgCore"]),
69 .target(name: "OrgIndex", dependencies: ["OrgCore", .product(name: "GRDB", package: "GRDB.swift")]),
70 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
71 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]),
72 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"])
73 ]
74)
75```
76
77- [ ] **Step 2: Write the failing tests**
78
79```swift
80import OrgCore
81import Testing
82@testable import OrgIndex
83
84func record(_ path: String, _ text: String, kind: FileKind = .org, settings: IndexSettings = IndexSettings()) -> FileRecord {
85 FileRecord(path: path, root: "/notes", kind: kind, bytes: Array(text.utf8), mtime: 1, settings: settings)
86}
87
88struct FileRecordTests {
89 let text = """
90 #+FILETAGS: :work:
91 #+PROPERTY: OWNER team
92 * TODO [#A] Plan the launch :big:
93 SCHEDULED: <2026-10-05 Mon +1w -2d>
94 :PROPERTIES:
95 :ID: plan
96 :CATEGORY: launch
97 :END:
98 Draft the [[https://example.com][brief]] and see [[id:other]].
99 ** DONE Child
100 CLOSED: [2026-10-04 Sun 09:00]
101 :LOGBOOK:
102 CLOCK: [2026-10-04 Sun 08:00]--[2026-10-04 Sun 09:00] => 1:00
103 :END:
104 <2026-10-06 Tue>
105
106 """
107
108 @Test func headings() throws {
109 let headings = record("/notes/a.org", text).headings
110 #expect(headings.count == 2)
111 let plan = headings[0]
112 #expect(plan.todo == "TODO" && !plan.isDone && plan.priority == "A")
113 #expect(plan.orgID == "plan")
114 #expect(plan.outlinePath == ["Plan the launch"])
115 #expect(plan.body.contains("Draft the"))
116 #expect(!plan.body.contains("Child"))
117 #expect(plan.tags == [TagRow(name: "work", inherited: true), TagRow(name: "big", inherited: false)])
118 #expect(plan.links == [LinkRow(type: "https", target: "https://example.com"), LinkRow(type: "id", target: "id:other")])
119 #expect(plan.timestamps == [TimestampRow(kind: .scheduled, start: "2026-10-05", end: nil, repeater: "+1w", warning: "-2d")])
120
121 let child = headings[1]
122 #expect(child.parent == 0 && child.isDone)
123 #expect(child.outlinePath == ["Plan the launch", "Child"])
124 #expect(child.tags.map(\.name) == ["work", "big"])
125 #expect(child.clocks == [ClockRow(start: "2026-10-04T08:00", end: "2026-10-04T09:00", minutes: 60)])
126 #expect(child.timestamps.map(\.kind) == [.closed, .active])
127 #expect(child.properties == [PropertyRow(key: "CATEGORY", value: "launch", inherited: true)])
128 }
129
130 @Test func propertyInheritanceFollowsSettings() {
131 let settings = IndexSettings(semantic: SemanticSettings(propertyInheritance: .all))
132 let child = record("/notes/a.org", text, settings: settings).headings[1]
133 #expect(child.properties.contains(PropertyRow(key: "OWNER", value: "team", inherited: true)))
134 }
135
136 @Test func archiveTag() {
137 let headings = record("/notes/a.org", "* a :ARCHIVE:\n** b\n* c\n").headings
138 #expect(headings.map(\.archived) == [true, true, false])
139 }
140
141 @Test func conflictCopiesHaveNoHeadings() {
142 #expect(record("/notes/a.sync-conflict-1.org", "* a\n", kind: .conflict).headings.isEmpty)
143 }
144}
145```
146
147- [ ] **Step 3: Implement**
148
149```swift
150import CryptoKit
151import Foundation
152import OrgCore
153
154public enum FileKind: String, Sendable {
155 case org
156 case archive
157 /// A Syncthing conflict copy: listed, never indexed for search, agenda or IDs.
158 case conflict
159}
160
161/// Everything that decides what an index row means. Bump `version` when a semantic setting
162/// changes, so files indexed under the old settings are indexed again.
163public struct IndexSettings: Sendable, Equatable {
164 public var version: Int
165 public var org: OrgSettings
166 public var semantic: SemanticSettings
167
168 public init(version: Int = 1, org: OrgSettings = .default, semantic: SemanticSettings = .default) {
169 self.version = version
170 self.org = org
171 self.semantic = semantic
172 }
173}
174
175public struct TagRow: Sendable, Equatable {
176 public let name: String
177 public let inherited: Bool
178}
179
180public struct PropertyRow: Sendable, Equatable {
181 /// Upper-cased.
182 public let key: String
183 public let value: String
184 public let inherited: Bool
185}
186
187public struct TimestampRow: Sendable, Equatable {
188 public enum Kind: String, Sendable { case scheduled, deadline, closed, active, inactive }
189 public let kind: Kind
190 /// `2026-10-04` or `2026-10-04T10:00`.
191 public let start: String
192 public let end: String?
193 /// As written: `+1w`, `.+2d/3d`.
194 public let repeater: String?
195 /// As written: `-2d`, `--1w`.
196 public let warning: String?
197}
198
199public struct ClockRow: Sendable, Equatable {
200 public let start: String
201 public let end: String?
202 public let minutes: Int?
203}
204
205public struct LinkRow: Sendable, Equatable {
206 /// The scheme before the first colon (`id`, `file`, `https`), or `fuzzy`.
207 public let type: String
208 public let target: String
209}
210
211public struct HeadingRecord: Sendable, Equatable {
212 /// Position in document order; parents refer to it.
213 public let ordinal: Int
214 public let parent: Int?
215 /// The section's UTF-16 range in the file's text.
216 public let start: Int
217 public let end: Int
218 public let level: Int
219 public let todo: String?
220 public let isDone: Bool
221 public let priority: String?
222 public let title: String
223 public let outlinePath: [String]
224 public let orgID: String?
225 /// Tagged `ARCHIVE`, directly or by inheritance.
226 public let archived: Bool
227 /// The heading's own text, without the heading line and child sections.
228 public let body: String
229 public let tags: [TagRow]
230 public let properties: [PropertyRow]
231 public let timestamps: [TimestampRow]
232 public let clocks: [ClockRow]
233 public let links: [LinkRow]
234}
235
236/// One file's index rows, computed without touching the database.
237public struct FileRecord: Sendable, Equatable {
238 public let path: String
239 public let root: String
240 public let kind: FileKind
241 public let size: Int
242 public let mtime: Double
243 /// SHA-256 of the bytes, hex.
244 public let hash: String
245 public let settingsVersion: Int
246 public let headings: [HeadingRecord]
247
248 public static func hash(_ bytes: [UInt8]) -> String {
249 SHA256.hash(data: Data(bytes)).map { String(format: "%02x", $0) }.joined()
250 }
251
252 public init(path: String, root: String, kind: FileKind, bytes: [UInt8], mtime: Double, settings: IndexSettings) {
253 self.path = path
254 self.root = root
255 self.kind = kind
256 self.size = bytes.count
257 self.mtime = mtime
258 self.hash = Self.hash(bytes)
259 self.settingsVersion = settings.version
260 let source = SourceText(bytes: bytes)
261 headings = kind == .conflict ? [] : Self.headings(source.text, settings: settings)
262 }
263
264 static func headings(_ text: String, settings: IndexSettings) -> [HeadingRecord] {
265 let model = DocumentModel(tree: OrgParser.parse(text, defaults: settings.org), settings: settings.semantic)
266 let utf16 = text.utf16
267 func slice(_ range: Range<Int>) -> String {
268 let start = utf16.index(utf16.startIndex, offsetBy: range.lowerBound)
269 let end = utf16.index(utf16.startIndex, offsetBy: range.upperBound)
270 return String(text[start..<end])
271 }
272 var firstChild: [Int: Int] = [:]
273 for (index, heading) in model.headings.enumerated() {
274 if let parent = heading.parent, firstChild[parent] == nil { firstChild[parent] = index }
275 }
276
277 return model.headings.enumerated().map { index, heading in
278 let bodyEnd = firstChild[index].map { model.headings[$0].sectionRange.lowerBound } ?? heading.sectionRange.upperBound
279 let tags = model.tags(of: index)
280 return HeadingRecord(
281 ordinal: index,
282 parent: heading.parent,
283 start: heading.sectionRange.lowerBound,
284 end: heading.sectionRange.upperBound,
285 level: heading.level,
286 todo: heading.todo,
287 isDone: heading.isDone,
288 priority: heading.priority,
289 title: heading.title,
290 outlinePath: model.outlinePath(of: index),
291 orgID: heading.id,
292 archived: tags.contains { $0.value == "ARCHIVE" },
293 body: slice(heading.headingRange.upperBound..<bodyEnd),
294 tags: tags.map { TagRow(name: $0.value, inherited: $0.source != .heading(index)) },
295 properties: properties(model, index),
296 timestamps: timestamps(heading),
297 clocks: heading.clocks.map {
298 ClockRow(start: format($0.start), end: $0.end.map(format), minutes: $0.minutes)
299 },
300 links: heading.links.map { target in
301 let scheme = target.prefix { $0 != ":" }
302 let isScheme = scheme.count < target.count && !scheme.isEmpty && scheme.allSatisfy { $0.isLetter || $0 == "-" }
303 return LinkRow(type: isScheme ? scheme.lowercased() : "fuzzy", target: target)
304 }
305 )
306 }
307 }
308
309 /// Own properties, plus inherited ones for keys that inherit.
310 static func properties(_ model: DocumentModel, _ index: Int) -> [PropertyRow] {
311 var keys: [String] = []
312 func add(_ entries: [Property], inheritedOnly: Bool) {
313 for entry in entries {
314 let key = entry.key.uppercased()
315 if !keys.contains(key), !inheritedOnly || model.inherits(key) { keys.append(key) }
316 }
317 }
318 add(model.headings[index].properties, inheritedOnly: false)
319 add(model.fileProperties, inheritedOnly: true)
320 for ancestor in model.ancestors(of: index) { add(model.headings[ancestor].properties, inheritedOnly: true) }
321 return keys.compactMap { key in
322 model.property(key, of: index).map {
323 PropertyRow(key: key, value: $0.value, inherited: $0.source != .heading(index))
324 }
325 }
326 }
327
328 static func timestamps(_ heading: HeadingInfo) -> [TimestampRow] {
329 var rows: [TimestampRow] = []
330 func add(_ stamp: Timestamp?, _ kind: TimestampRow.Kind) {
331 guard let stamp else { return }
332 rows.append(TimestampRow(
333 kind: kind,
334 start: format(stamp.start),
335 end: stamp.end.map(format),
336 repeater: stamp.repeater.map {
337 $0.kind.rawValue + format($0.interval) + ($0.habitDeadline.map { "/" + format($0) } ?? "")
338 },
339 warning: stamp.warning.map { ($0.firstOccurrenceOnly ? "--" : "-") + format($0.interval) }
340 ))
341 }
342 add(heading.scheduled, .scheduled)
343 add(heading.deadline, .deadline)
344 add(heading.closed, .closed)
345 for stamp in heading.timestamps { add(stamp, stamp.active ? .active : .inactive) }
346 return rows
347 }
348
349 static func format(_ point: Timestamp.Point) -> String {
350 let date = String(format: "%04d-%02d-%02d", point.year, point.month, point.day)
351 guard let hour = point.hour, let minute = point.minute else { return date }
352 return date + String(format: "T%02d:%02d", hour, minute)
353 }
354
355 static func format(_ interval: Timestamp.Interval) -> String {
356 "\(interval.value)\(interval.unit.rawValue)"
357 }
358}
359```
360
361- [ ] **Step 4: Run to verify pass, then commit**
362
363Run: `swift test --filter FileRecordTests`
364
365```bash
366git add Package.swift Package.resolved Sources/OrgIndex/FileRecord.swift Tests/OrgIndexTests/FileRecordTests.swift
367git commit -m "Add index file records"
368```
369
370---
371
372### Task 2: Index store
373
374**Files:**
375- Create: `Sources/OrgIndex/IndexStore.swift`
376- Test: `Tests/OrgIndexTests/IndexStoreTests.swift`
377
378**Interfaces:**
379- Consumes: `FileRecord` (Task 1).
380- Produces: `IndexStore(path:)`, `write(_:)`, `apply(_ change: IndexChange)`, `fileStates(root:) -> [String: FileState]`, `files()`, `search(_:overlay:limit:)`, `headings(withID:overlay:)`, `duplicateIDs(overlay:)`; `FileState`, `HeadingLocation`, `IndexChange`.
381
382- [ ] **Step 1: Write the failing tests**
383
384```swift
385import OrgCore
386import Testing
387@testable import OrgIndex
388
389struct IndexStoreTests {
390 @Test func searchFindsTitlesAndBodies() throws {
391 let store = try IndexStore()
392 try store.write(record("/notes/a.org", "* Groceries\nbuy apples\n* Taxes\nfile forms\n"))
393 #expect(try store.search("appl").map(\.title) == ["Groceries"])
394 #expect(try store.search("tax").map(\.title) == ["Taxes"])
395 #expect(try store.search("file forms").map(\.title) == ["Taxes"])
396 #expect(try store.search("\"oops").isEmpty)
397 #expect(try store.search(" ").isEmpty)
398 }
399
400 @Test func rewritingAFileReplacesItsRows() throws {
401 let store = try IndexStore()
402 try store.write(record("/notes/a.org", "* Old title\n"))
403 try store.write(record("/notes/a.org", "* New title\n"))
404 #expect(try store.search("old").isEmpty)
405 #expect(try store.search("new").count == 1)
406 }
407
408 @Test func removeMoveAndTouch() throws {
409 let store = try IndexStore()
410 try store.write(record("/notes/a.org", "* Alpha\n"))
411 try store.write(record("/notes/b.org", "* Beta\n"))
412 var change = IndexChange()
413 change.removals = ["/notes/a.org"]
414 change.moves = [(from: "/notes/b.org", to: "/notes/c.org", mtime: 5)]
415 try store.apply(change)
416 #expect(try store.search("alpha").isEmpty)
417 #expect(try store.search("beta").map(\.path) == ["/notes/c.org"])
418 #expect(try store.fileStates(root: "/notes")["/notes/c.org"]?.mtime == 5)
419 }
420
421 @Test func overlayReplacesIndexedRows() throws {
422 let store = try IndexStore()
423 try store.write(record("/notes/a.org", "* Saved title\n"))
424 let overlay = ["/notes/a.org": record("/notes/a.org", "* Unsaved title\n")]
425 #expect(try store.search("saved", overlay: overlay).isEmpty)
426 #expect(try store.search("unsaved", overlay: overlay).map(\.title) == ["Unsaved title"])
427 }
428
429 @Test func idsAndDuplicates() throws {
430 let store = try IndexStore()
431 try store.write(record("/notes/a.org", "* A\n:PROPERTIES:\n:ID: x\n:END:\n"))
432 try store.write(record("/notes/b.org", "* B\n:PROPERTIES:\n:ID: y\n:END:\n"))
433 #expect(try store.headings(withID: "x").map(\.path) == ["/notes/a.org"])
434 #expect(try store.duplicateIDs().isEmpty)
435 let overlay = ["/notes/b.org": record("/notes/b.org", "* B\n:PROPERTIES:\n:ID: x\n:END:\n")]
436 #expect(try store.headings(withID: "x", overlay: overlay).map(\.path) == ["/notes/a.org", "/notes/b.org"])
437 #expect(try store.duplicateIDs(overlay: overlay).keys.sorted() == ["x"])
438 }
439
440 @Test func fileStatesAndKinds() throws {
441 let store = try IndexStore()
442 let a = record("/notes/a.org", "* A\n")
443 try store.write(a)
444 try store.write(record("/notes/a.sync-conflict-1.org", "* A\n", kind: .conflict))
445 let states = try store.fileStates(root: "/notes")
446 #expect(states["/notes/a.org"] == FileState(kind: .org, size: a.size, mtime: 1, hash: a.hash, settingsVersion: 1))
447 #expect(try store.files().map(\.kind) == [.org, .conflict])
448 #expect(try store.search("a").map(\.path) == ["/notes/a.org"])
449 }
450}
451```
452
453- [ ] **Step 2: Implement**
454
455```swift
456import Foundation
457import GRDB
458
459/// What the index knows about a file without reading it.
460public struct FileState: Sendable, Equatable {
461 public let kind: FileKind
462 public let size: Int
463 public let mtime: Double
464 public let hash: String
465 public let settingsVersion: Int
466}
467
468/// A heading as found by a query. `contentHash` is the hash of the text the offsets refer to,
469/// so a caller can tell whether they still apply to an open buffer.
470public struct HeadingLocation: Sendable, Equatable {
471 public let path: String
472 public let ordinal: Int
473 public let title: String
474 public let start: Int
475 public let contentHash: String
476}
477
478/// One reconciliation's worth of changes, applied in a single transaction.
479public struct IndexChange: Sendable {
480 public var records: [FileRecord] = []
481 /// Unchanged content with a new modification time.
482 public var touches: [(path: String, mtime: Double)] = []
483 /// Renamed files whose content didn't change.
484 public var moves: [(from: String, to: String, mtime: Double)] = []
485 public var removals: [String] = []
486
487 public init() {}
488
489 public var isEmpty: Bool { records.isEmpty && touches.isEmpty && moves.isEmpty && removals.isEmpty }
490}
491
492/// The SQLite index. A cache: deleting it loses nothing that the files don't hold.
493public final class IndexStore: Sendable {
494 let database: DatabaseQueue
495
496 /// `path` nil opens an in-memory index.
497 public init(path: String? = nil) throws {
498 database = try path.map { try DatabaseQueue(path: $0) } ?? DatabaseQueue()
499 try Self.migrator.migrate(database)
500 }
501
502 static var migrator: DatabaseMigrator {
503 var migrator = DatabaseMigrator()
504 migrator.registerMigration("v1") { db in
505 try db.execute(sql: """
506 CREATE TABLE files (
507 id INTEGER PRIMARY KEY,
508 path TEXT NOT NULL UNIQUE,
509 root TEXT NOT NULL,
510 kind TEXT NOT NULL,
511 size INTEGER NOT NULL,
512 mtime REAL NOT NULL,
513 hash TEXT NOT NULL,
514 settings_version INTEGER NOT NULL,
515 parsed_at REAL NOT NULL
516 );
517 CREATE INDEX files_root ON files(root);
518 CREATE TABLE headings (
519 id INTEGER PRIMARY KEY,
520 file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
521 ordinal INTEGER NOT NULL,
522 parent_ordinal INTEGER,
523 start_offset INTEGER NOT NULL,
524 end_offset INTEGER NOT NULL,
525 level INTEGER NOT NULL,
526 todo TEXT,
527 is_done INTEGER NOT NULL,
528 priority TEXT,
529 title TEXT NOT NULL,
530 outline_path TEXT NOT NULL,
531 org_id TEXT,
532 archived INTEGER NOT NULL
533 );
534 CREATE INDEX headings_file ON headings(file_id);
535 CREATE INDEX headings_org_id ON headings(org_id);
536 CREATE TABLE tags (
537 heading_id INTEGER NOT NULL REFERENCES headings(id) ON DELETE CASCADE,
538 tag TEXT NOT NULL,
539 inherited INTEGER NOT NULL
540 );
541 CREATE INDEX tags_heading ON tags(heading_id);
542 CREATE TABLE properties (
543 heading_id INTEGER NOT NULL REFERENCES headings(id) ON DELETE CASCADE,
544 key TEXT NOT NULL,
545 value TEXT NOT NULL,
546 inherited INTEGER NOT NULL
547 );
548 CREATE INDEX properties_heading ON properties(heading_id);
549 CREATE TABLE timestamps (
550 heading_id INTEGER NOT NULL REFERENCES headings(id) ON DELETE CASCADE,
551 kind TEXT NOT NULL,
552 start_at TEXT NOT NULL,
553 end_at TEXT,
554 repeater TEXT,
555 warning TEXT
556 );
557 CREATE INDEX timestamps_heading ON timestamps(heading_id);
558 CREATE TABLE clocks (
559 heading_id INTEGER NOT NULL REFERENCES headings(id) ON DELETE CASCADE,
560 start_at TEXT NOT NULL,
561 end_at TEXT,
562 minutes INTEGER
563 );
564 CREATE INDEX clocks_heading ON clocks(heading_id);
565 CREATE TABLE links (
566 heading_id INTEGER NOT NULL REFERENCES headings(id) ON DELETE CASCADE,
567 type TEXT NOT NULL,
568 target TEXT NOT NULL
569 );
570 CREATE INDEX links_heading ON links(heading_id);
571 CREATE VIRTUAL TABLE headings_fts USING fts5(title, body, tokenize = 'unicode61 remove_diacritics 2');
572 CREATE TRIGGER headings_fts_delete AFTER DELETE ON headings BEGIN
573 DELETE FROM headings_fts WHERE rowid = old.id;
574 END;
575 """)
576 }
577 return migrator
578 }
579
580 // MARK: - Writing
581
582 public func write(_ record: FileRecord) throws {
583 var change = IndexChange()
584 change.records = [record]
585 try apply(change)
586 }
587
588 public func apply(_ change: IndexChange) throws {
589 guard !change.isEmpty else { return }
590 try database.write { db in
591 for path in change.removals {
592 try db.execute(sql: "DELETE FROM files WHERE path = ?", arguments: [path])
593 }
594 for move in change.moves {
595 try db.execute(sql: "UPDATE files SET path = ?, mtime = ? WHERE path = ?", arguments: [move.to, move.mtime, move.from])
596 }
597 for touch in change.touches {
598 try db.execute(sql: "UPDATE files SET mtime = ? WHERE path = ?", arguments: [touch.mtime, touch.path])
599 }
600 for record in change.records {
601 try insert(record, db)
602 }
603 }
604 }
605
606 private func insert(_ record: FileRecord, _ db: Database) throws {
607 try db.execute(sql: "DELETE FROM files WHERE path = ?", arguments: [record.path])
608 try db.execute(
609 sql: """
610 INSERT INTO files (path, root, kind, size, mtime, hash, settings_version, parsed_at)
611 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
612 """,
613 arguments: [record.path, record.root, record.kind.rawValue, record.size, record.mtime, record.hash,
614 record.settingsVersion, Date().timeIntervalSince1970]
615 )
616 let fileID = db.lastInsertedRowID
617 for heading in record.headings {
618 try db.execute(
619 sql: """
620 INSERT INTO headings (file_id, ordinal, parent_ordinal, start_offset, end_offset, level, todo,
621 is_done, priority, title, outline_path, org_id, archived)
622 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
623 """,
624 arguments: [fileID, heading.ordinal, heading.parent, heading.start, heading.end, heading.level, heading.todo,
625 heading.isDone, heading.priority, heading.title, heading.outlinePath.joined(separator: "\u{1F}"),
626 heading.orgID, heading.archived]
627 )
628 let id = db.lastInsertedRowID
629 try db.execute(sql: "INSERT INTO headings_fts (rowid, title, body) VALUES (?, ?, ?)", arguments: [id, heading.title, heading.body])
630 for tag in heading.tags {
631 try db.execute(sql: "INSERT INTO tags VALUES (?, ?, ?)", arguments: [id, tag.name, tag.inherited])
632 }
633 for property in heading.properties {
634 try db.execute(sql: "INSERT INTO properties VALUES (?, ?, ?, ?)", arguments: [id, property.key, property.value, property.inherited])
635 }
636 for stamp in heading.timestamps {
637 try db.execute(
638 sql: "INSERT INTO timestamps VALUES (?, ?, ?, ?, ?, ?)",
639 arguments: [id, stamp.kind.rawValue, stamp.start, stamp.end, stamp.repeater, stamp.warning]
640 )
641 }
642 for clock in heading.clocks {
643 try db.execute(sql: "INSERT INTO clocks VALUES (?, ?, ?, ?)", arguments: [id, clock.start, clock.end, clock.minutes])
644 }
645 for link in heading.links {
646 try db.execute(sql: "INSERT INTO links VALUES (?, ?, ?)", arguments: [id, link.type, link.target])
647 }
648 }
649 }
650
651 // MARK: - Reading
652
653 /// Indexed files under `root`, by path.
654 public func fileStates(root: String) throws -> [String: FileState] {
655 try database.read { db in
656 let rows = try Row.fetchAll(db, sql: "SELECT path, kind, size, mtime, hash, settings_version FROM files WHERE root = ?", arguments: [root])
657 var states: [String: FileState] = [:]
658 for row in rows {
659 states[row["path"]] = FileState(
660 kind: FileKind(rawValue: row["kind"]) ?? .org,
661 size: row["size"], mtime: row["mtime"], hash: row["hash"], settingsVersion: row["settings_version"]
662 )
663 }
664 return states
665 }
666 }
667
668 public func files() throws -> [(path: String, kind: FileKind)] {
669 try database.read { db in
670 try Row.fetchAll(db, sql: "SELECT path, kind FROM files ORDER BY path").map {
671 (path: $0["path"], kind: FileKind(rawValue: $0["kind"]) ?? .org)
672 }
673 }
674 }
675
676 // MARK: - Queries
677 //
678 // `overlay` holds records for open documents with unsaved edits, keyed by path. Their rows
679 // replace that file's indexed rows in every query.
680
681 /// Headings whose title or body contain every word of `query` as a word prefix.
682 public func search(_ query: String, overlay: [String: FileRecord] = [:], limit: Int = 50) throws -> [HeadingLocation] {
683 let terms = query.split(whereSeparator: \.isWhitespace).map(String.init)
684 guard !terms.isEmpty else { return [] }
685 let match = terms.map { "\"" + $0.replacingOccurrences(of: "\"", with: "\"\"") + "\"*" }.joined(separator: " ")
686 let indexed = try database.read { db in
687 try Row.fetchAll(
688 db,
689 sql: """
690 SELECT f.path, f.hash, h.ordinal, h.title, h.start_offset
691 FROM headings_fts
692 JOIN headings h ON h.id = headings_fts.rowid
693 JOIN files f ON f.id = h.file_id
694 WHERE headings_fts MATCH ?
695 ORDER BY bm25(headings_fts)
696 LIMIT ?
697 """,
698 arguments: [match, limit + overlay.count * 10]
699 ).map(location)
700 }
701 // Unsaved buffers get the same word-prefix matching as the full-text index.
702 let prefixes = terms.flatMap(Self.words)
703 let live = overlay.values.sorted { $0.path < $1.path }.flatMap { record in
704 record.headings.filter { heading in
705 let words = Self.words(heading.title + "\n" + heading.body)
706 return prefixes.allSatisfy { prefix in words.contains { $0.hasPrefix(prefix) } }
707 }.map { location(record, $0) }
708 }
709 return Array((live + indexed.filter { overlay[$0.path] == nil }).prefix(limit))
710 }
711
712 /// Headings with `:ID: id`. More than one means the ID is duplicated.
713 public func headings(withID id: String, overlay: [String: FileRecord] = [:]) throws -> [HeadingLocation] {
714 let indexed = try database.read { db in
715 try Row.fetchAll(
716 db,
717 sql: """
718 SELECT f.path, f.hash, h.ordinal, h.title, h.start_offset
719 FROM headings h JOIN files f ON f.id = h.file_id
720 WHERE h.org_id = ? ORDER BY f.path, h.ordinal
721 """,
722 arguments: [id]
723 ).map(location)
724 }
725 let live = overlay.values.sorted { $0.path < $1.path }.flatMap { record in
726 record.headings.filter { $0.orgID == id }.map { location(record, $0) }
727 }
728 return indexed.filter { overlay[$0.path] == nil } + live
729 }
730
731 /// IDs used by more than one heading.
732 public func duplicateIDs(overlay: [String: FileRecord] = [:]) throws -> [String: [HeadingLocation]] {
733 let indexed = try database.read { db in
734 try Row.fetchAll(
735 db,
736 sql: """
737 SELECT h.org_id, f.path, f.hash, h.ordinal, h.title, h.start_offset
738 FROM headings h JOIN files f ON f.id = h.file_id
739 WHERE h.org_id IN (SELECT org_id FROM headings WHERE org_id IS NOT NULL GROUP BY org_id HAVING count(*) > 1)
740 OR (h.org_id IS NOT NULL AND ? > 0)
741 ORDER BY f.path, h.ordinal
742 """,
743 arguments: [overlay.count]
744 ).map { (id: $0["org_id"] as String, location: location($0)) }
745 }
746 var byID: [String: [HeadingLocation]] = [:]
747 for row in indexed where overlay[row.location.path] == nil {
748 byID[row.id, default: []].append(row.location)
749 }
750 for record in overlay.values.sorted(by: { $0.path < $1.path }) {
751 for heading in record.headings {
752 if let id = heading.orgID { byID[id, default: []].append(location(record, heading)) }
753 }
754 }
755 return byID.filter { $0.value.count > 1 }
756 }
757
758 /// Lower-cased runs of letters and digits, as the unicode61 tokenizer splits them.
759 static func words(_ text: String) -> [String] {
760 text.lowercased().split { !$0.isLetter && !$0.isNumber }.map(String.init)
761 }
762
763 private func location(_ row: Row) -> HeadingLocation {
764 HeadingLocation(path: row["path"], ordinal: row["ordinal"], title: row["title"], start: row["start_offset"], contentHash: row["hash"])
765 }
766
767 private func location(_ record: FileRecord, _ heading: HeadingRecord) -> HeadingLocation {
768 HeadingLocation(path: record.path, ordinal: heading.ordinal, title: heading.title, start: heading.start, contentHash: record.hash)
769 }
770}
771```
772
773- [ ] **Step 3: Run to verify pass, then commit**
774
775Run: `swift test --filter IndexStoreTests`
776
777```bash
778git add Sources/OrgIndex/IndexStore.swift Tests/OrgIndexTests/IndexStoreTests.swift
779git commit -m "Add SQLite index store with search and overlay"
780```
781
782---
783
784### Task 3: Discovery and agenda scope
785
786**Files:**
787- Modify: `Package.swift` (add `OrgWorkspace`)
788- Create: `Sources/OrgWorkspace/Discovery.swift`
789- Test: `Tests/OrgWorkspaceTests/Folder.swift`, `Tests/OrgWorkspaceTests/DiscoveryTests.swift`
790
791**Interfaces:**
792- Consumes: `FileKind` (Task 1).
793- Produces: `WorkspaceRoot(url:)` with `resolve()`, `DiscoveredFile`, `Discovery`, `FileClassification`, `WorkspaceScanner.classify(_:)`, `WorkspaceScanner.scan(_:)`, `AgendaScope(globs:)` with `contains(relativePath:kind:)`; test helper `Folder`.
794
795- [ ] **Step 1: Add the target**
796
797```swift
798// swift-tools-version: 6.2
799import PackageDescription
800
801let package = Package(
802 name: "Orgstar",
803 platforms: [.macOS(.v26), .iOS(.v26)],
804 products: [
805 .library(name: "OrgCore", targets: ["OrgCore"]),
806 .library(name: "OrgDocument", targets: ["OrgDocument"]),
807 .library(name: "OrgIndex", targets: ["OrgIndex"]),
808 .library(name: "OrgWorkspace", targets: ["OrgWorkspace"])
809 ],
810 dependencies: [
811 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1")
812 ],
813 targets: [
814 .target(name: "OrgCore"),
815 .target(name: "OrgDocument", dependencies: ["OrgCore"]),
816 .target(name: "OrgIndex", dependencies: ["OrgCore", .product(name: "GRDB", package: "GRDB.swift")]),
817 .target(name: "OrgWorkspace", dependencies: ["OrgIndex"]),
818 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
819 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]),
820 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
821 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"])
822 ]
823)
824```
825
826- [ ] **Step 2: Write the failing tests**
827
828```swift
829import Foundation
830
831/// A temporary folder that removes itself.
832final class Folder {
833 let url: URL
834
835 init() throws {
836 url = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)").standardizedFileURL
837 try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
838 }
839
840 deinit {
841 try? FileManager.default.removeItem(at: url)
842 }
843
844 @discardableResult
845 func write(_ path: String, _ text: String) throws -> URL {
846 let file = url.appendingPathComponent(path)
847 try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
848 try Data(text.utf8).write(to: file)
849 return file.standardizedFileURL
850 }
851}
852```
853
854```swift
855import Foundation
856import OrgIndex
857import Testing
858@testable import OrgWorkspace
859
860struct ClassificationTests {
861 @Test(arguments: [
862 ("a.org", FileClassification.file(.org)),
863 ("a.org_archive", .file(.archive)),
864 ("a.sync-conflict-20261004-120000-ABCDEFG.org", .file(.conflict)),
865 (".a.org.icloud", .placeholder("a.org")),
866 (".syncthing.a.org.tmp", .ignored),
867 (".a.org.orgstar-1234", .ignored),
868 ("a.org~", .ignored),
869 ("#a.org#", .ignored),
870 ("a.md", .ignored),
871 (".hidden.org", .ignored),
872 ])
873 func classify(name: String, expected: FileClassification) {
874 #expect(WorkspaceScanner.classify(name) == expected)
875 }
876
877 @Test func agendaScope() {
878 #expect(AgendaScope().contains(relativePath: "a.org", kind: .org))
879 #expect(!AgendaScope().contains(relativePath: "a.org_archive", kind: .archive))
880 #expect(!AgendaScope().contains(relativePath: "a.org", kind: .conflict))
881 let work = AgendaScope(globs: ["work/*"])
882 #expect(work.contains(relativePath: "work/deep/a.org", kind: .org))
883 #expect(!work.contains(relativePath: "home/a.org", kind: .org))
884 }
885}
886
887struct ScanTests {
888 @Test func skipsHiddenFoldersAndFindsPlaceholders() throws {
889 let folder = try Folder()
890 try folder.write("a.org", "* a\n")
891 try folder.write("sub/b.org_archive", "* b\n")
892 try folder.write(".git/c.org", "* c\n")
893 try folder.write(".stversions/d.org", "* d\n")
894 try folder.write("sub/.e.org.icloud", "")
895 try folder.write("notes.txt", "")
896 let discovery = try WorkspaceScanner.scan(folder.url)
897 #expect(discovery.files.map(\.url.lastPathComponent) == ["a.org", "b.org_archive"])
898 #expect(discovery.placeholders.map(\.lastPathComponent) == ["e.org"])
899 }
900}
901```
902
903- [ ] **Step 3: Implement**
904
905```swift
906import Foundation
907import OrgIndex
908
909/// A folder the user added. Identified by its bookmark, so a moved folder is followed.
910public struct WorkspaceRoot: Sendable, Equatable, Codable {
911 public let bookmark: Data
912 /// The path when the bookmark was made or last refreshed, for display.
913 public var path: String
914
915 public init(url: URL) throws {
916 bookmark = try url.bookmarkData(options: [], includingResourceValuesForKeys: nil, relativeTo: nil)
917 path = url.standardizedFileURL.path
918 }
919
920 /// The folder's current location. `isStale` means the bookmark should be made again.
921 public func resolve() throws -> (url: URL, isStale: Bool) {
922 var isStale = false
923 let url = try URL(resolvingBookmarkData: bookmark, options: [], relativeTo: nil, bookmarkDataIsStale: &isStale)
924 _ = url.startAccessingSecurityScopedResource()
925 return (url.standardizedFileURL, isStale)
926 }
927}
928
929public struct DiscoveredFile: Sendable, Equatable {
930 public let url: URL
931 public let kind: FileKind
932 public let size: Int
933 public let mtime: Double
934}
935
936public struct Discovery: Sendable, Equatable {
937 public var files: [DiscoveredFile] = []
938 /// iCloud files not downloaded yet, by the name they will have.
939 public var placeholders: [URL] = []
940}
941
942public enum FileClassification: Sendable, Equatable {
943 case file(FileKind)
944 /// An iCloud placeholder (`.name.org.icloud`) for the file at the given name.
945 case placeholder(String)
946 case ignored
947}
948
949public enum WorkspaceScanner {
950 /// What a file name means to the workspace. Syncthing temp files, our own temp and backup
951 /// files, Emacs backups and auto-saves, and other hidden files are ignored.
952 public static func classify(_ name: String) -> FileClassification {
953 if name.hasPrefix(".") {
954 if name.hasSuffix(".icloud") {
955 let inner = String(name.dropFirst().dropLast(".icloud".count))
956 if case .file = classify(inner) { return .placeholder(inner) }
957 }
958 return .ignored
959 }
960 if name.hasSuffix("~") || name.hasPrefix("#") { return .ignored }
961 let conflict = name.contains(".sync-conflict-")
962 if name.hasSuffix(".org") { return .file(conflict ? .conflict : .org) }
963 if name.hasSuffix(".org_archive") { return .file(conflict ? .conflict : .archive) }
964 return .ignored
965 }
966
967 /// Every org file under `root`, skipping hidden folders (`.git`, `.stfolder`, `.stversions`).
968 public static func scan(_ root: URL) throws -> Discovery {
969 let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
970 guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else {
971 return Discovery()
972 }
973 var discovery = Discovery()
974 for case let url as URL in enumerator {
975 let values = try url.resourceValues(forKeys: Set(keys))
976 let name = url.lastPathComponent
977 if values.isDirectory == true {
978 if name.hasPrefix(".") { enumerator.skipDescendants() }
979 continue
980 }
981 switch classify(name) {
982 case .file(let kind):
983 discovery.files.append(DiscoveredFile(
984 url: url.standardizedFileURL,
985 kind: kind,
986 size: values.fileSize ?? 0,
987 mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
988 ))
989 case .placeholder(let inner):
990 discovery.placeholders.append(url.deletingLastPathComponent().appendingPathComponent(inner).standardizedFileURL)
991 case .ignored:
992 break
993 }
994 }
995 discovery.files.sort { $0.url.path < $1.url.path }
996 return discovery
997 }
998}
999
1000/// Which files feed the agenda: `.org` files (not archives or conflict copies) matching one of
1001/// the globs, relative to their root. No globs means every `.org` file.
1002public struct AgendaScope: Sendable, Equatable {
1003 public var globs: [String]
1004
1005 public init(globs: [String] = []) {
1006 self.globs = globs
1007 }
1008
1009 public func contains(relativePath: String, kind: FileKind) -> Bool {
1010 guard kind == .org else { return false }
1011 guard !globs.isEmpty else { return true }
1012 // Without FNM_PATHNAME, `*` also matches `/`, so `work/*` covers nested folders.
1013 return globs.contains { fnmatch($0, relativePath, 0) == 0 }
1014 }
1015}
1016```
1017
1018- [ ] **Step 4: Run to verify pass, then commit**
1019
1020Run: `swift test --filter "ClassificationTests|ScanTests"`
1021
1022```bash
1023git add Package.swift Sources/OrgWorkspace/Discovery.swift Tests/OrgWorkspaceTests/Folder.swift Tests/OrgWorkspaceTests/DiscoveryTests.swift
1024git commit -m "Add workspace discovery and agenda scope"
1025```
1026
1027---
1028
1029### Task 4: Reconciliation
1030
1031**Files:**
1032- Create: `Sources/OrgWorkspace/Reconciler.swift`
1033- Test: `Tests/OrgWorkspaceTests/ReconcileTests.swift`
1034
1035**Interfaces:**
1036- Consumes: Tasks 1–3.
1037- Produces: `Reconciler(index:settings:)` with `reconcile(root:)` and `reconcile(root:paths:)`, `ReconcileReport`.
1038
1039- [ ] **Step 1: Write the failing tests**
1040
1041```swift
1042import Foundation
1043import OrgIndex
1044import Testing
1045@testable import OrgWorkspace
1046
1047struct ReconcileTests {
1048 @Test func indexesUpdatesMovesAndRemoves() throws {
1049 let folder = try Folder()
1050 let index = try IndexStore()
1051 let reconciler = Reconciler(index: index, settings: IndexSettings())
1052 let a = try folder.write("a.org", "* Alpha\n")
1053 try folder.write("b.org", "* Beta\n")
1054
1055 var report = try reconciler.reconcile(root: folder.url)
1056 #expect(report.indexed.count == 2)
1057 #expect(try index.search("alpha").map(\.path) == [a.path])
1058
1059 report = try reconciler.reconcile(root: folder.url)
1060 #expect(report.unchanged == 2 && report.indexed.isEmpty)
1061
1062 try FileManager.default.moveItem(at: folder.url.appendingPathComponent("b.org"), to: folder.url.appendingPathComponent("c.org"))
1063 try folder.write("a.org", "* Gamma\n")
1064 report = try reconciler.reconcile(root: folder.url)
1065 #expect(report.moved.map { URL(fileURLWithPath: $0).lastPathComponent } == ["c.org"])
1066 #expect(report.indexed == [a.path])
1067 #expect(try index.search("gamma").count == 1)
1068 #expect(try index.search("beta").map { URL(fileURLWithPath: $0.path).lastPathComponent } == ["c.org"])
1069
1070 try FileManager.default.removeItem(at: a)
1071 report = try reconciler.reconcile(root: folder.url)
1072 #expect(report.removed == [a.path])
1073 #expect(try index.search("gamma").isEmpty)
1074 }
1075
1076 @Test func touchedFileWithSameContentIsNotReparsed() throws {
1077 let folder = try Folder()
1078 let index = try IndexStore()
1079 let reconciler = Reconciler(index: index, settings: IndexSettings())
1080 let a = try folder.write("a.org", "* Alpha\n")
1081 _ = try reconciler.reconcile(root: folder.url)
1082 try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: 60)], ofItemAtPath: a.path)
1083 #expect(try reconciler.reconcile(root: folder.url).touched == [a.path])
1084 }
1085
1086 @Test func newSettingsVersionReindexes() throws {
1087 let folder = try Folder()
1088 let index = try IndexStore()
1089 try folder.write("a.org", "* Alpha\n")
1090 _ = try Reconciler(index: index, settings: IndexSettings(version: 1)).reconcile(root: folder.url)
1091 #expect(try Reconciler(index: index, settings: IndexSettings(version: 2)).reconcile(root: folder.url).indexed.count == 1)
1092 }
1093
1094 @Test func conflictCopiesAreListedButNotSearchable() throws {
1095 let folder = try Folder()
1096 let index = try IndexStore()
1097 try folder.write("a.sync-conflict-1.org", "* Alpha\n")
1098 _ = try Reconciler(index: index, settings: IndexSettings()).reconcile(root: folder.url)
1099 #expect(try index.files().map(\.kind) == [.conflict])
1100 #expect(try index.search("alpha").isEmpty)
1101 }
1102
1103 @Test func reconcilingChangedPaths() throws {
1104 let folder = try Folder()
1105 let index = try IndexStore()
1106 let reconciler = Reconciler(index: index, settings: IndexSettings())
1107 let a = try folder.write("a.org", "* Alpha\n")
1108 let b = try folder.write("b.org", "* Beta\n")
1109 _ = try reconciler.reconcile(root: folder.url)
1110 try folder.write("a.org", "* Delta\n")
1111 try FileManager.default.removeItem(at: b)
1112 let report = try reconciler.reconcile(root: folder.url, paths: [a, b])
1113 #expect(report.indexed == [a.path])
1114 #expect(report.removed == [b.path])
1115 }
1116
1117 @Test func rootBookmarkResolves() throws {
1118 let folder = try Folder()
1119 let root = try WorkspaceRoot(url: folder.url)
1120 #expect(try root.resolve().url.resolvingSymlinksInPath() == folder.url.resolvingSymlinksInPath())
1121 }
1122}
1123```
1124
1125- [ ] **Step 2: Implement**
1126
1127```swift
1128import Foundation
1129import OrgIndex
1130
1131public struct ReconcileReport: Sendable, Equatable {
1132 public var indexed: [String] = []
1133 public var touched: [String] = []
1134 public var moved: [String] = []
1135 public var removed: [String] = []
1136 public var unchanged = 0
1137 /// iCloud files asked to download; they are indexed once they arrive.
1138 public var placeholders: [URL] = []
1139}
1140
1141/// Brings the index in line with the files under a root. File events are only hints, so this
1142/// is what runs at launch, after dropped events, and for every batch of changed paths.
1143public struct Reconciler: Sendable {
1144 public let index: IndexStore
1145 public let settings: IndexSettings
1146
1147 public init(index: IndexStore, settings: IndexSettings) {
1148 self.index = index
1149 self.settings = settings
1150 }
1151
1152 /// Reconciles the whole root.
1153 public func reconcile(root: URL) throws -> ReconcileReport {
1154 let discovery = try WorkspaceScanner.scan(root)
1155 for placeholder in discovery.placeholders {
1156 try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder)
1157 }
1158 var report = try reconcile(root: root, found: discovery.files, known: index.fileStates(root: root.path))
1159 report.placeholders = discovery.placeholders
1160 return report
1161 }
1162
1163 /// Reconciles only `paths` under the root, for a batch of file events.
1164 public func reconcile(root: URL, paths: [URL]) throws -> ReconcileReport {
1165 let wanted = Set(paths.map(\.standardizedFileURL.path))
1166 let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) }
1167 var found: [DiscoveredFile] = []
1168 for url in paths.map(\.standardizedFileURL) {
1169 guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent),
1170 let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]),
1171 values.isDirectory != true else { continue }
1172 found.append(DiscoveredFile(
1173 url: url, kind: kind, size: values.fileSize ?? 0,
1174 mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
1175 ))
1176 }
1177 return try reconcile(root: root, found: found, known: known)
1178 }
1179
1180 private func reconcile(root: URL, found: [DiscoveredFile], known: [String: FileState]) throws -> ReconcileReport {
1181 var report = ReconcileReport()
1182 var change = IndexChange()
1183 var candidates: [(file: DiscoveredFile, bytes: [UInt8], hash: String)] = []
1184
1185 for file in found {
1186 let path = file.url.path
1187 if let state = known[path], state.size == file.size, state.mtime == file.mtime,
1188 state.settingsVersion == settings.version, state.kind == file.kind {
1189 report.unchanged += 1
1190 continue
1191 }
1192 guard let data = try? Data(contentsOf: file.url) else { continue }
1193 let bytes = [UInt8](data)
1194 let hash = FileRecord.hash(bytes)
1195 if let state = known[path], state.hash == hash, state.settingsVersion == settings.version, state.kind == file.kind {
1196 change.touches.append((path, file.mtime))
1197 report.touched.append(path)
1198 } else {
1199 candidates.append((file, bytes, hash))
1200 }
1201 }
1202
1203 // A file that vanished and one that appeared with the same content is a rename.
1204 let foundPaths = Set(found.map(\.url.path))
1205 var missing = known.filter { !foundPaths.contains($0.key) }
1206 for candidate in candidates {
1207 let path = candidate.file.url.path
1208 if known[path] == nil,
1209 let (oldPath, _) = missing.first(where: {
1210 $0.value.hash == candidate.hash && $0.value.settingsVersion == settings.version && $0.value.kind == candidate.file.kind
1211 }) {
1212 missing[oldPath] = nil
1213 change.moves.append((oldPath, path, candidate.file.mtime))
1214 report.moved.append(path)
1215 } else {
1216 change.records.append(FileRecord(
1217 path: path, root: root.path, kind: candidate.file.kind,
1218 bytes: candidate.bytes, mtime: candidate.file.mtime, settings: settings
1219 ))
1220 report.indexed.append(path)
1221 }
1222 }
1223 change.removals = missing.keys.sorted()
1224 report.removed = change.removals
1225 try index.apply(change)
1226 return report
1227 }
1228}
1229```
1230
1231- [ ] **Step 3: Run to verify pass, then commit**
1232
1233Run: `swift test --filter ReconcileTests`
1234
1235```bash
1236git add Sources/OrgWorkspace/Reconciler.swift Tests/OrgWorkspaceTests/ReconcileTests.swift
1237git commit -m "Add index reconciliation"
1238```
1239
1240---
1241
1242### Task 5: FSEvents watcher
1243
1244**Files:**
1245- Create: `Sources/OrgWorkspace/FSEventsWatcher.swift`
1246- Test: `Tests/OrgWorkspaceTests/WatcherTests.swift`
1247
1248**Interfaces:**
1249- Produces (macOS): `WatchEvent` (`.changed(URL)`, `.rescan(URL)`), `FSEventsWatcher(roots:latency:handler:)` with `start()`, `stop()`.
1250
1251- [ ] **Step 1: Write the failing test**
1252
1253```swift
1254import Foundation
1255import OrgIndex
1256import Testing
1257@testable import OrgWorkspace
1258
1259struct WatcherTests {
1260 @Test func reportsAWrittenFile() throws {
1261 let folder = try Folder()
1262 let received = Received()
1263 let watcher = FSEventsWatcher(roots: [folder.url.resolvingSymlinksInPath()], latency: 0.05) { received.add($0) }
1264 watcher.start()
1265 defer { watcher.stop() }
1266 Thread.sleep(forTimeInterval: 0.2)
1267 try folder.write("a.org", "* a\n")
1268 #expect(received.wait(for: "a.org", timeout: 5))
1269 }
1270}
1271
1272final class Received: @unchecked Sendable {
1273 private let lock = NSLock()
1274 private var events: [WatchEvent] = []
1275
1276 func add(_ batch: [WatchEvent]) {
1277 lock.withLock { events += batch }
1278 }
1279
1280 func wait(for name: String, timeout: TimeInterval) -> Bool {
1281 let deadline = Date(timeIntervalSinceNow: timeout)
1282 while Date() < deadline {
1283 let found = lock.withLock {
1284 events.contains { if case .changed(let url) = $0 { return url.lastPathComponent == name } else { return false } }
1285 }
1286 if found { return true }
1287 Thread.sleep(forTimeInterval: 0.05)
1288 }
1289 return false
1290 }
1291}
1292```
1293
1294- [ ] **Step 2: Implement**
1295
1296```swift
1297#if os(macOS)
1298import CoreServices
1299import Foundation
1300
1301public enum WatchEvent: Sendable, Equatable {
1302 case changed(URL)
1303 /// Events were dropped or coalesced for this folder, or a root moved: rescan it.
1304 case rescan(URL)
1305}
1306
1307/// FSEvents with file-level events. Delivers batches on a private queue.
1308public final class FSEventsWatcher: @unchecked Sendable {
1309 private let paths: [String]
1310 private let latency: CFTimeInterval
1311 private let handler: @Sendable ([WatchEvent]) -> Void
1312 private let queue = DispatchQueue(label: "orgstar.fsevents")
1313 private var stream: FSEventStreamRef?
1314
1315 public init(roots: [URL], latency: CFTimeInterval = 0.3, handler: @escaping @Sendable ([WatchEvent]) -> Void) {
1316 paths = roots.map(\.path)
1317 self.latency = latency
1318 self.handler = handler
1319 }
1320
1321 deinit {
1322 stop()
1323 }
1324
1325 public func start() {
1326 guard stream == nil else { return }
1327 var context = FSEventStreamContext(
1328 version: 0, info: Unmanaged.passUnretained(self).toOpaque(), retain: nil, release: nil, copyDescription: nil
1329 )
1330 let flags = FSEventStreamCreateFlags(
1331 kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes
1332 | kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagWatchRoot
1333 )
1334 let callback: FSEventStreamCallback = { _, info, count, eventPaths, eventFlags, _ in
1335 guard let info else { return }
1336 let watcher = Unmanaged<FSEventsWatcher>.fromOpaque(info).takeUnretainedValue()
1337 let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] ?? []
1338 watcher.deliver(paths: paths, flags: Array(UnsafeBufferPointer(start: eventFlags, count: count)))
1339 }
1340 guard let stream = FSEventStreamCreate(
1341 nil, callback, &context, paths as CFArray, FSEventStreamEventId(kFSEventStreamEventIdSinceNow), latency, flags
1342 ) else { return }
1343 FSEventStreamSetDispatchQueue(stream, queue)
1344 FSEventStreamStart(stream)
1345 self.stream = stream
1346 }
1347
1348 public func stop() {
1349 guard let stream else { return }
1350 FSEventStreamStop(stream)
1351 FSEventStreamInvalidate(stream)
1352 FSEventStreamRelease(stream)
1353 self.stream = nil
1354 }
1355
1356 private func deliver(paths eventPaths: [String], flags: [FSEventStreamEventFlags]) {
1357 let rescanFlags = FSEventStreamEventFlags(
1358 kFSEventStreamEventFlagMustScanSubDirs | kFSEventStreamEventFlagUserDropped
1359 | kFSEventStreamEventFlagKernelDropped | kFSEventStreamEventFlagRootChanged
1360 )
1361 var events: [WatchEvent] = []
1362 for (path, flag) in zip(eventPaths, flags) {
1363 let url = URL(fileURLWithPath: path).standardizedFileURL
1364 if flag & rescanFlags != 0 {
1365 let root = paths.first { path.hasPrefix($0) } ?? path
1366 events.append(.rescan(URL(fileURLWithPath: root).standardizedFileURL))
1367 } else {
1368 events.append(.changed(url))
1369 }
1370 }
1371 if !events.isEmpty { handler(events) }
1372 }
1373}
1374#endif
1375```
1376
1377- [ ] **Step 3: Run everything, the iOS build and the gates**
1378
1379Run: `swift test`, then `xcodebuild -scheme OrgWorkspace -destination 'generic/platform=iOS' build`.
1380Gates: reconcile a folder of 10,000 org files into an empty index (under 30 s), then again with no changes (under 2 s), in a release build on the reference machine.
1381
1382- [ ] **Step 4: Commit**
1383
1384```bash
1385git add Sources/OrgWorkspace/FSEventsWatcher.swift Tests/OrgWorkspaceTests/WatcherTests.swift
1386git commit -m "Add FSEvents watcher"
1387```