macsync/docs/adding-a-module.md

214 lines
7.8 KiB
Markdown
Raw Permalink Normal View History

# Adding a module
A walk-through for adding a 7th (or 8th) module — the real 7th was `ICallsSync`
for CallHistory (read-only DB module); the example below uses a hypothetical
`ISafariSync` for Safari bookmarks. Follow the patterns already in place for the
seven shipping modules. Every step cites where the equivalent already lives.
## 0. Decide direction and OS surface
- **Direction**: read-only (like iPhoto), 2-way via SendQueueClient (like
iCal), or 2-way via AppleScript (like iNotes)?
- **OS surface**: which framework? Safari bookmarks live in
`~/Library/Safari/Bookmarks.plist`; assume a plist reader for this example.
- **Interval**: how often to tick? Cheap reads (EventKit) every 300s; slow
AppleScript every 600s; near-realtime queries (chat.db) every 30s.
Document this in a one-line summary you can paste into the README table.
## 1. SwiftPM target
Add a target block in `Package.swift` modelled on
`Package.swift:80-87` (`IReminderSync` is the closest analogue):
```swift
.target(
name: "ISafariSync",
dependencies: ["MacSyncShared"],
path: "@packages/isafari/Sources/ISafariSync"
// linkerSettings only if you need a framework like EventKit
),
.testTarget(
name: "ISafariSyncTests",
dependencies: ["ISafariSync"],
path: "@packages/isafari/Tests/ISafariSyncTests"
),
```
Add `"ISafariSync"` to the `MacSyncApp` executable dependencies block
(`Package.swift:97-110`).
## 2. Module source files
Create four files under `@packages/isafari/Sources/ISafariSync/`:
| File | Role | Example to copy |
|-------------------|-----------------------------------------------------|------------------------------------------------------------------------------|
| `Reader.swift` | Talks to the OS (plist parse, EventKit, AppleScript)| `@packages/inotes/Sources/INoteSync/Reader.swift` |
| `APIClient.swift` | Typed HTTPS calls over `BaseAPIClient` | `@packages/inotes/Sources/INoteSync/APIClient.swift` (esp. lines 60-130) |
| `SyncManager.swift`| Subclass of `BaseSyncManager<Stats, Error>` | `@packages/inotes/Sources/INoteSync/SyncManager.swift` (full file) |
| `Sender.swift` | Only if bidirectional | `@packages/ical/Sources/ICalSync/Sender.swift` |
Skeleton for `SyncManager.swift`:
```swift
import MacSyncShared
public struct ISafariSyncStats: Equatable, Sendable {
public var bookmarkCount: Int = 0
public init() {}
}
public enum ISafariSyncError: Equatable, Sendable {
case none
case bookmarksUnavailable
case connectionFailed(String)
}
@MainActor
public final class SyncManager: BaseSyncManager<ISafariSyncStats, ISafariSyncError> {
public static let shared = SyncManager()
private init() {
super.init(
initialStats: ISafariSyncStats(),
noError: .none,
persistenceKey: "isafari", // becomes the UserDefaults watermark namespace
timerInterval: 600
)
}
public override func performSync() async {
// Reader.fetch -> APIClient.sync(payloads) -> stats update
}
}
```
If bidirectional, also override `didStartSync()` / `willStopSync()` and own a
`lazy var sendQueueClient: SendQueueClient<SafariSendTransport>`. The two
existing patterns:
- EventKit: `@packages/ical/Sources/ICalSync/SyncManager.swift:54-73`
- AppleScript: `@packages/inotes/Sources/INoteSync/SyncManager.swift:53-72`
## 3. Server entities, features, surfaces
The repo follows a strict three-layer split:
- `src/server/src/entities/<entity>/` — schema migrations, types, repo
- `src/server/src/features/<module>/` — service-level functions (`syncBookmarks`, `queryBookmarks`)
- `src/server/src/surfaces/{client,my,admin}/<module>.ts` — HTTP routes
Files to create for read-only iSafari (mirror `inotes/`):
```
src/server/src/entities/bookmark/{schema.ts,types.ts,repo.ts,index.ts}
src/server/src/features/isafari/{index.ts,syncService.ts}
src/server/src/surfaces/client/isafari.ts
src/server/src/surfaces/my/bookmarks.ts
```
If bidirectional, also create:
```
src/server/src/entities/bookmarkSendItem/{schema.ts,types.ts,index.ts}
src/server/src/features/isafari/sendService.ts
src/server/src/surfaces/admin/bookmark-send-queue.ts
```
`bookmarkSendItem/schema.ts` reuses the table-builder
(`src/server/src/entities/noteSendItem/schema.ts:6-8` for the pattern):
```ts
import { sendQueueTableSql } from '@/shared/sendQueue/SendQueueRepo';
export const bookmarkSendItemMigrations = [
{ id: '2026-05-13_bookmark_send_queue_initial',
sql: sendQueueTableSql('icloud.bookmark_send_queue') },
];
```
`features/isafari/sendService.ts` (mirroring
`src/server/src/features/inotes/sendService.ts:11-19`):
```ts
const BOOKMARK_SEND_ACTIONS = ['create_bookmark', 'update_bookmark', 'delete_bookmark'] as const;
const payloadSchema = z.object({ /* ... */ });
const repo = createSendQueueRepo({
tableName: 'icloud.bookmark_send_queue',
payloadSchema,
allowedActions: BOOKMARK_SEND_ACTIONS,
});
```
## 4. Wire the routes
Three index files need a one-line edit each (the rest of the surface tree is
discovered automatically):
- `src/server/src/surfaces/client/index.ts:11-19` — add
`.route('/isafari', isafariClientRouter)`
- `src/server/src/surfaces/my/index.ts:11-19` — add
`.route('/bookmarks', bookmarksMyRouter)`
- `src/server/src/surfaces/admin/index.ts:10-17` — add
`.route('/bookmark-send-queue', bookmarkSendQueueAdminRouter)` if bidirectional
Migrations also need registration in `src/server/src/app/server.ts:36-52`:
```ts
...bookmarkMigrations,
...bookmarkSendItemMigrations, // if bidirectional
```
## 5. Web tab
Create `web/src/api/bookmarks.ts` and `web/src/tabs/Bookmarks/index.tsx`,
modelled on `web/src/api/notes.ts` and `web/src/tabs/Notes/index.tsx`. Then
add a route in `web/src/App.tsx:55-65`:
```tsx
const BookmarksTab = lazy(() =>
import('./tabs/Bookmarks').then((m) => ({ default: m.BookmarksTab })),
);
// inside <Routes>:
<Route path="/bookmarks" element={<BookmarksTab />} />
```
And a nav entry inside `web/src/layout/AppShell` (not pasted here — copy the
Notes entry).
## 6. App manifest & docs
- `app.manifest.yaml:6-12` — append `- isafari` to the `modules:` list.
- `CLAUDE.md:7-29` — extend the structure block.
- Add `docs/modules/isafari.md` modelled on
`docs/modules/inotes.md`.
## 7. Tests
Mirror an existing module's `Tests/<Module>SyncTests/` directory. Hermetic
test coverage is limited to what does not need the OS — see
[modules/inotes.md](./modules/inotes.md#tests) for the breakdown.
For the server, follow `src/server/src/shared/sendQueue/__tests__/SendQueueRepo.test.ts`
(plan reference: `~/.claude/plans/magical-tumbling-peach.md:66`) for
queue round-trip tests against a test database.
## Checklist
- [ ] `Package.swift` target + executable dep
- [ ] `Reader.swift`, `APIClient.swift`, `SyncManager.swift` (+ `Sender.swift`)
- [ ] Server `entities/<x>/` migration + types + repo
- [ ] Server `features/<x>/` service
- [ ] Server `surfaces/client/<x>.ts`, `surfaces/my/<x>.ts` (+ admin if bidirectional)
- [ ] Migrations registered in `app/server.ts`
- [ ] Routes registered in three `surfaces/*/index.ts`
- [ ] Web `api/<x>.ts`, `tabs/<X>/index.tsx`, App.tsx route, AppShell nav entry
- [ ] `app.manifest.yaml` module list
- [ ] `docs/modules/<x>.md`
- [ ] Tests: `@packages/<x>/Tests/` + `src/server/src/features/<x>/__tests__/`
- [ ] `swift test` green, `bun test` green, `bun run typecheck` green
(The real 7th module `ICallsSync` was added exactly this way: read-only,
GRDB snapshot reader for WAL freshness on CallHistory.storedata, no Sender,
local `/api/calls` + remote `/my/calls` + `/client/icalls`, plus `callHistoryDb`
status in LocalWebServer diagnostics.)