feat: init disk remote source

This commit is contained in:
DarkSky
2026-02-27 02:39:53 +08:00
parent 895e774569
commit 6557e5d01d
51 changed files with 6793 additions and 158 deletions
+3 -1
View File
@@ -11,9 +11,11 @@ affine_common = { workspace = true, features = ["hashcash"] }
affine_media_capture = { path = "./media_capture" }
affine_nbstore = { workspace = true, features = ["napi"] }
affine_sqlite_v1 = { path = "./sqlite_v1" }
chrono = { workspace = true }
napi = { workspace = true }
napi-derive = { workspace = true }
once_cell = { workspace = true }
sha3 = { workspace = true }
sqlx = { workspace = true, default-features = false, features = [
"chrono",
"macros",
@@ -24,6 +26,7 @@ sqlx = { workspace = true, default-features = false, features = [
] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["full"] }
y-octo = { workspace = true }
[target.'cfg(not(target_os = "linux"))'.dependencies]
mimalloc = { workspace = true }
@@ -32,7 +35,6 @@ mimalloc = { workspace = true }
mimalloc = { workspace = true, features = ["local_dynamic_tls"] }
[dev-dependencies]
chrono = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
+45
View File
@@ -40,6 +40,51 @@ export declare function decodeAudio(buf: Uint8Array, destSampleRate?: number | u
/** Decode audio file into a Float32Array */
export declare function decodeAudioSync(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null): Float32Array
export declare class DiskSync {
constructor()
startSession(sessionId: string, options: DiskSessionOptions): Promise<NapiResult<undefined>>
stopSession(sessionId: string): Promise<NapiResult<undefined>>
applyLocalUpdate(sessionId: string, update: DiskDocUpdateInput, origin?: string | undefined | null): Promise<NapiResult<DiskDocClock>>
pullEvents(sessionId: string): Promise<NapiResult<Array<DiskSyncEvent>>>
subscribeEvents(sessionId: string, callback: ((err: Error | null, arg: DiskSyncEvent) => void)): Promise<NapiResult<DiskSyncSubscriber>>
}
export declare class DiskSyncSubscriber {
unsubscribe(): Promise<NapiResult<undefined>>
}
export interface DiskDocClock {
docId: string
timestamp: Date
}
export interface DiskDocUpdateInput {
docId: string
bin: Uint8Array
editor?: string
}
export interface DiskSessionOptions {
workspaceId: string
syncFolder: string
}
export interface DiskSyncDocUpdateEvent {
docId: string
bin: Uint8Array
timestamp: Date
editor?: string
}
export interface DiskSyncEvent {
type: string
update?: DiskSyncDocUpdateEvent
docId?: string
timestamp?: Date
origin?: string
message?: string
}
export declare function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
+54 -52
View File
@@ -77,8 +77,8 @@ function requireNative() {
try {
const binding = require('@affine/native-android-arm64')
const bindingPackageVersion = require('@affine/native-android-arm64/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -93,8 +93,8 @@ function requireNative() {
try {
const binding = require('@affine/native-android-arm-eabi')
const bindingPackageVersion = require('@affine/native-android-arm-eabi/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -114,8 +114,8 @@ function requireNative() {
try {
const binding = require('@affine/native-win32-x64-gnu')
const bindingPackageVersion = require('@affine/native-win32-x64-gnu/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -130,8 +130,8 @@ function requireNative() {
try {
const binding = require('@affine/native-win32-x64-msvc')
const bindingPackageVersion = require('@affine/native-win32-x64-msvc/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -147,8 +147,8 @@ function requireNative() {
try {
const binding = require('@affine/native-win32-ia32-msvc')
const bindingPackageVersion = require('@affine/native-win32-ia32-msvc/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -163,8 +163,8 @@ function requireNative() {
try {
const binding = require('@affine/native-win32-arm64-msvc')
const bindingPackageVersion = require('@affine/native-win32-arm64-msvc/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -182,8 +182,8 @@ function requireNative() {
try {
const binding = require('@affine/native-darwin-universal')
const bindingPackageVersion = require('@affine/native-darwin-universal/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -198,8 +198,8 @@ function requireNative() {
try {
const binding = require('@affine/native-darwin-x64')
const bindingPackageVersion = require('@affine/native-darwin-x64/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -214,8 +214,8 @@ function requireNative() {
try {
const binding = require('@affine/native-darwin-arm64')
const bindingPackageVersion = require('@affine/native-darwin-arm64/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -234,8 +234,8 @@ function requireNative() {
try {
const binding = require('@affine/native-freebsd-x64')
const bindingPackageVersion = require('@affine/native-freebsd-x64/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -250,8 +250,8 @@ function requireNative() {
try {
const binding = require('@affine/native-freebsd-arm64')
const bindingPackageVersion = require('@affine/native-freebsd-arm64/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -271,8 +271,8 @@ function requireNative() {
try {
const binding = require('@affine/native-linux-x64-musl')
const bindingPackageVersion = require('@affine/native-linux-x64-musl/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -287,8 +287,8 @@ function requireNative() {
try {
const binding = require('@affine/native-linux-x64-gnu')
const bindingPackageVersion = require('@affine/native-linux-x64-gnu/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -305,8 +305,8 @@ function requireNative() {
try {
const binding = require('@affine/native-linux-arm64-musl')
const bindingPackageVersion = require('@affine/native-linux-arm64-musl/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -321,8 +321,8 @@ function requireNative() {
try {
const binding = require('@affine/native-linux-arm64-gnu')
const bindingPackageVersion = require('@affine/native-linux-arm64-gnu/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -339,8 +339,8 @@ function requireNative() {
try {
const binding = require('@affine/native-linux-arm-musleabihf')
const bindingPackageVersion = require('@affine/native-linux-arm-musleabihf/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -355,8 +355,8 @@ function requireNative() {
try {
const binding = require('@affine/native-linux-arm-gnueabihf')
const bindingPackageVersion = require('@affine/native-linux-arm-gnueabihf/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -373,8 +373,8 @@ function requireNative() {
try {
const binding = require('@affine/native-linux-loong64-musl')
const bindingPackageVersion = require('@affine/native-linux-loong64-musl/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -389,8 +389,8 @@ function requireNative() {
try {
const binding = require('@affine/native-linux-loong64-gnu')
const bindingPackageVersion = require('@affine/native-linux-loong64-gnu/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -407,8 +407,8 @@ function requireNative() {
try {
const binding = require('@affine/native-linux-riscv64-musl')
const bindingPackageVersion = require('@affine/native-linux-riscv64-musl/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -423,8 +423,8 @@ function requireNative() {
try {
const binding = require('@affine/native-linux-riscv64-gnu')
const bindingPackageVersion = require('@affine/native-linux-riscv64-gnu/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -440,8 +440,8 @@ function requireNative() {
try {
const binding = require('@affine/native-linux-ppc64-gnu')
const bindingPackageVersion = require('@affine/native-linux-ppc64-gnu/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -456,8 +456,8 @@ function requireNative() {
try {
const binding = require('@affine/native-linux-s390x-gnu')
const bindingPackageVersion = require('@affine/native-linux-s390x-gnu/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -476,8 +476,8 @@ function requireNative() {
try {
const binding = require('@affine/native-openharmony-arm64')
const bindingPackageVersion = require('@affine/native-openharmony-arm64/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -492,8 +492,8 @@ function requireNative() {
try {
const binding = require('@affine/native-openharmony-x64')
const bindingPackageVersion = require('@affine/native-openharmony-x64/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -508,8 +508,8 @@ function requireNative() {
try {
const binding = require('@affine/native-openharmony-arm')
const bindingPackageVersion = require('@affine/native-openharmony-arm/package.json').version
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
}
return binding
} catch (e) {
@@ -579,6 +579,8 @@ module.exports.AudioCaptureSession = nativeBinding.AudioCaptureSession
module.exports.ShareableContent = nativeBinding.ShareableContent
module.exports.decodeAudio = nativeBinding.decodeAudio
module.exports.decodeAudioSync = nativeBinding.decodeAudioSync
module.exports.DiskSync = nativeBinding.DiskSync
module.exports.DiskSyncSubscriber = nativeBinding.DiskSyncSubscriber
module.exports.mintChallengeResponse = nativeBinding.mintChallengeResponse
module.exports.verifyChallengeResponse = nativeBinding.verifyChallengeResponse
module.exports.DocStorage = nativeBinding.DocStorage
@@ -0,0 +1,174 @@
use super::types::FrontmatterMeta;
pub(crate) fn parse_frontmatter(markdown: &str) -> (FrontmatterMeta, String) {
let normalized = markdown.replace("\r\n", "\n");
if !normalized.starts_with("---\n") {
return (FrontmatterMeta::default(), normalized);
}
let rest = &normalized[4..];
let Some(end) = rest.find("\n---\n") else {
return (FrontmatterMeta::default(), normalized);
};
let frontmatter_block = &rest[..end];
let body = rest[(end + 5)..].to_string();
let mut meta = FrontmatterMeta::default();
let mut in_tags_block = false;
for raw_line in frontmatter_block.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if in_tags_block && line.starts_with('-') {
let value = normalize_scalar(line.trim_start_matches('-').trim());
if !value.is_empty() {
meta.tags.get_or_insert_with(Vec::new).push(value);
}
continue;
}
in_tags_block = false;
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim();
let value = value.trim();
match key {
"id" => {
let normalized = normalize_scalar(value);
if !normalized.is_empty() {
meta.id = Some(normalized);
}
}
"title" => {
let normalized = normalize_scalar(value);
// Preserve explicit empty titles (`title: ""`) so round-trip hashing can
// distinguish them from a missing title field.
meta.title = Some(normalized);
}
"favorite" => {
meta.favorite = parse_bool(value);
}
"trash" => {
meta.trash = parse_bool(value);
}
"tags" => {
if value.is_empty() {
in_tags_block = true;
} else {
let tags = parse_tags(value);
if !tags.is_empty() {
meta.tags = Some(tags);
}
}
}
_ => {}
}
}
(meta, body)
}
pub(crate) fn render_frontmatter(meta: &FrontmatterMeta, body: &str) -> String {
let mut lines = Vec::new();
lines.push("---".to_string());
if let Some(id) = meta.id.as_ref() {
lines.push(format!("id: {}", quote_yaml_scalar(id)));
}
if let Some(title) = meta.title.as_ref() {
lines.push(format!("title: {}", quote_yaml_scalar(title)));
}
if let Some(tags) = normalize_tags(meta.tags.clone()) {
if tags.is_empty() {
lines.push("tags: []".to_string());
} else {
lines.push("tags:".to_string());
for tag in tags {
lines.push(format!(" - {}", quote_yaml_scalar(&tag)));
}
}
}
if let Some(favorite) = meta.favorite {
lines.push(format!("favorite: {}", favorite));
}
if let Some(trash) = meta.trash {
lines.push(format!("trash: {}", trash));
}
lines.push("---".to_string());
lines.push(String::new());
let mut rendered = lines.join("\n");
rendered.push_str(body.trim_start_matches('\n'));
if !rendered.ends_with('\n') {
rendered.push('\n');
}
rendered
}
fn normalize_scalar(value: &str) -> String {
value.trim().trim_matches('"').trim_matches('\'').to_string()
}
pub(crate) fn parse_bool(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"true" | "yes" | "1" => Some(true),
"false" | "no" | "0" => Some(false),
_ => None,
}
}
pub(crate) fn parse_tags(value: &str) -> Vec<String> {
let trimmed = value.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let inner = &trimmed[1..trimmed.len() - 1];
return inner
.split(',')
.map(normalize_scalar)
.filter(|value| !value.is_empty())
.collect();
}
trimmed
.split(',')
.map(normalize_scalar)
.filter(|value| !value.is_empty())
.collect()
}
pub(crate) fn normalize_tags(tags: Option<Vec<String>>) -> Option<Vec<String>> {
tags.map(|values| {
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect()
})
}
fn quote_yaml_scalar(value: &str) -> String {
if value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.')
{
return value.to_string();
}
let escaped = value.replace('"', "\\\"");
format!("\"{}\"", escaped)
}
@@ -0,0 +1,203 @@
use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use chrono::NaiveDateTime;
use napi::{
bindgen_prelude::{Error as NapiError, Result as NapiResult, Uint8Array},
threadsafe_function::ThreadsafeFunction,
};
use napi_derive::napi;
use once_cell::sync::Lazy;
use tokio::sync::RwLock;
mod frontmatter;
mod root_meta;
mod session;
mod state_db;
mod types;
mod utils;
#[cfg(test)]
mod tests;
use session::DiskSession;
static SESSIONS: Lazy<RwLock<HashMap<String, Arc<DiskSession>>>> = Lazy::new(|| RwLock::new(HashMap::new()));
static NEXT_SUBSCRIBER_ID: AtomicU64 = AtomicU64::new(1);
#[napi(object)]
pub struct DiskSessionOptions {
pub workspace_id: String,
pub sync_folder: String,
}
#[napi(object)]
pub struct DiskDocUpdateInput {
pub doc_id: String,
#[napi(ts_type = "Uint8Array")]
pub bin: Uint8Array,
pub editor: Option<String>,
}
#[napi(object)]
pub struct DiskDocClock {
pub doc_id: String,
pub timestamp: NaiveDateTime,
}
#[napi(object)]
pub struct DiskSyncDocUpdateEvent {
pub doc_id: String,
pub bin: Uint8Array,
pub timestamp: NaiveDateTime,
pub editor: Option<String>,
}
impl Clone for DiskSyncDocUpdateEvent {
fn clone(&self) -> Self {
Self {
doc_id: self.doc_id.clone(),
bin: Uint8Array::new(self.bin.as_ref().to_vec()),
timestamp: self.timestamp,
editor: self.editor.clone(),
}
}
}
#[derive(Clone)]
#[napi(object)]
pub struct DiskSyncEvent {
pub r#type: String,
pub update: Option<DiskSyncDocUpdateEvent>,
pub doc_id: Option<String>,
pub timestamp: Option<NaiveDateTime>,
pub origin: Option<String>,
pub message: Option<String>,
}
#[napi]
pub struct DiskSync;
#[napi]
pub struct DiskSyncSubscriber {
session_id: String,
subscriber_id: u64,
}
#[napi]
impl DiskSync {
#[napi(constructor)]
pub fn new() -> Self {
Self
}
#[napi]
pub async fn start_session(&self, session_id: String, options: DiskSessionOptions) -> NapiResult<()> {
{
let sessions = SESSIONS.read().await;
if sessions.contains_key(&session_id) {
return Ok(());
}
}
let session = DiskSession::new(options).await.map_err(to_napi_error)?;
session.queue_ready_event().await.map_err(to_napi_error)?;
session.scan_once().await.map_err(to_napi_error)?;
let mut sessions = SESSIONS.write().await;
sessions.insert(session_id, Arc::new(session));
Ok(())
}
#[napi]
pub async fn stop_session(&self, session_id: String) -> NapiResult<()> {
let mut sessions = SESSIONS.write().await;
if let Some(session) = sessions.remove(&session_id) {
session.close().await;
}
Ok(())
}
#[napi]
pub async fn apply_local_update(
&self,
session_id: String,
update: DiskDocUpdateInput,
origin: Option<String>,
) -> NapiResult<DiskDocClock> {
let session = {
let sessions = SESSIONS.read().await;
sessions
.get(&session_id)
.cloned()
.ok_or_else(|| to_napi_error(format!("disk session {} is not started", session_id)))?
};
session.apply_local_update(update, origin).await.map_err(to_napi_error)
}
#[napi]
pub async fn pull_events(&self, session_id: String) -> NapiResult<Vec<DiskSyncEvent>> {
let session = {
let sessions = SESSIONS.read().await;
sessions
.get(&session_id)
.cloned()
.ok_or_else(|| to_napi_error(format!("disk session {} is not started", session_id)))?
};
session.pull_events().await.map_err(to_napi_error)
}
#[napi]
pub async fn subscribe_events(
&self,
session_id: String,
callback: ThreadsafeFunction<DiskSyncEvent, ()>,
) -> NapiResult<DiskSyncSubscriber> {
let session = {
let sessions = SESSIONS.read().await;
sessions
.get(&session_id)
.cloned()
.ok_or_else(|| to_napi_error(format!("disk session {} is not started", session_id)))?
};
let subscriber_id = NEXT_SUBSCRIBER_ID.fetch_add(1, Ordering::Relaxed);
session
.add_subscriber(subscriber_id, callback)
.await
.map_err(to_napi_error)?;
Ok(DiskSyncSubscriber {
session_id,
subscriber_id,
})
}
}
#[napi]
impl DiskSyncSubscriber {
#[napi]
pub async fn unsubscribe(&self) -> NapiResult<()> {
let session = {
let sessions = SESSIONS.read().await;
sessions.get(&self.session_id).cloned()
};
if let Some(session) = session {
session.remove_subscriber(self.subscriber_id).await;
}
Ok(())
}
}
fn to_napi_error(message: impl Into<String>) -> NapiError {
NapiError::from_reason(message.into())
}
@@ -0,0 +1,345 @@
use std::collections::HashMap;
use chrono::Utc;
use y_octo::{Any, Array, Doc, Map, Value};
use super::{
frontmatter::{parse_bool, parse_tags},
types::FrontmatterMeta,
utils::{is_empty_update, load_doc_or_new},
};
pub(crate) fn build_root_meta_update(
existing_root: &[u8],
workspace_id: &str,
doc_id: &str,
meta: &FrontmatterMeta,
) -> Result<Vec<u8>, String> {
let doc = load_doc_or_new(existing_root, Some(workspace_id))?;
let state_before = doc.get_state_vector();
let mut meta_map = doc
.get_or_create_map("meta")
.map_err(|err| format!("failed to open root meta map: {}", err))?;
let mut pages = ensure_pages_array(&doc, &mut meta_map)?;
let mut found = false;
for idx in 0..pages.len() {
let Some(mut page) = pages.get(idx).and_then(|value| value.to_map()) else {
continue;
};
if get_string_from_map(&page, "id").as_deref() == Some(doc_id) {
apply_page_meta(&doc, &mut page, doc_id, meta)?;
found = true;
break;
}
}
if !found {
let page_map = doc
.create_map()
.map_err(|err| format!("failed to create root page map: {}", err))?;
let idx = pages.len();
pages
.insert(idx, Value::Map(page_map))
.map_err(|err| format!("failed to insert root page map: {}", err))?;
if let Some(mut page) = pages.get(idx).and_then(|value| value.to_map()) {
apply_page_meta(&doc, &mut page, doc_id, meta)?;
}
}
doc
.encode_state_as_update_v1(&state_before)
.map_err(|err| format!("failed to encode root meta update: {}", err))
}
pub(crate) fn extract_root_meta_for_doc(root_bin: &[u8], doc_id: &str) -> Result<Option<FrontmatterMeta>, String> {
let metas = extract_all_root_meta(root_bin)?;
Ok(metas.get(doc_id).cloned())
}
pub(crate) fn extract_all_root_meta(root_bin: &[u8]) -> Result<HashMap<String, FrontmatterMeta>, String> {
if is_empty_update(root_bin) {
return Ok(HashMap::new());
}
let doc = load_doc_or_new(root_bin, None)?;
let meta = match doc.get_map("meta") {
Ok(meta) => meta,
Err(_) => return Ok(HashMap::new()),
};
let pages_value = meta.get("pages");
let mut result = HashMap::new();
if let Some(pages) = pages_value.as_ref().and_then(|value| value.to_array()) {
for page_value in pages.iter() {
let Some(page_map) = page_value.to_map() else {
continue;
};
let Some(doc_id) = get_string_from_map(&page_map, "id") else {
continue;
};
result.insert(doc_id.clone(), extract_meta_from_page_map(&page_map, Some(doc_id)));
}
return Ok(result);
}
if let Some(Any::Array(entries)) = pages_value.and_then(|value| value.to_any()) {
for entry in entries {
let Any::Object(values) = entry else {
continue;
};
let Some(Any::String(doc_id)) = values.get("id") else {
continue;
};
let mut meta = FrontmatterMeta::default();
meta.id = Some(doc_id.clone());
if let Some(Any::String(title)) = values.get("title") {
meta.title = Some(title.clone());
}
if let Some(tags) = values.get("tags") {
meta.tags = extract_tags_from_any(tags);
}
if let Some(value) = values.get("favorite") {
meta.favorite = any_to_bool(value);
}
if let Some(value) = values.get("trash") {
meta.trash = any_to_bool(value);
}
result.insert(doc_id.clone(), meta);
}
}
Ok(result)
}
fn apply_page_meta(doc: &Doc, page: &mut Map, doc_id: &str, meta: &FrontmatterMeta) -> Result<(), String> {
page
.insert("id".to_string(), Any::String(doc_id.to_string()))
.map_err(|err| format!("failed to set root meta id: {}", err))?;
if let Some(title) = meta.title.as_ref() {
page
.insert("title".to_string(), Any::String(title.clone()))
.map_err(|err| format!("failed to set root meta title: {}", err))?;
}
if let Some(tags) = super::frontmatter::normalize_tags(meta.tags.clone()) {
let mut tags_array = doc
.create_array()
.map_err(|err| format!("failed to create tags array: {}", err))?;
for tag in tags {
tags_array
.push(Any::String(tag))
.map_err(|err| format!("failed to push tag: {}", err))?;
}
page
.insert("tags".to_string(), Value::Array(tags_array))
.map_err(|err| format!("failed to set tags array: {}", err))?;
}
if let Some(favorite) = meta.favorite {
page
.insert("favorite".to_string(), if favorite { Any::True } else { Any::False })
.map_err(|err| format!("failed to set favorite metadata: {}", err))?;
}
if let Some(trash) = meta.trash {
page
.insert("trash".to_string(), if trash { Any::True } else { Any::False })
.map_err(|err| format!("failed to set trash metadata: {}", err))?;
}
let now_ms = Utc::now().timestamp_millis() as f64;
if !has_numeric_timestamp(page, "createDate") {
page
.insert("createDate".to_string(), Any::Float64(now_ms.into()))
.map_err(|err| format!("failed to set createDate metadata: {}", err))?;
}
page
.insert("updatedDate".to_string(), Any::Float64(now_ms.into()))
.map_err(|err| format!("failed to set updatedDate metadata: {}", err))?;
Ok(())
}
fn ensure_pages_array(doc: &Doc, meta: &mut Map) -> Result<Array, String> {
let pages_value = meta.get("pages");
if let Some(pages) = pages_value.as_ref().and_then(|value| value.to_array()) {
return Ok(pages);
}
if let Some(Any::Array(entries)) = pages_value.and_then(|value| value.to_any()) {
let mut pages = doc
.create_array()
.map_err(|err| format!("failed to create pages array: {}", err))?;
for entry in entries {
let value = any_to_value(doc, entry)?;
pages
.push(value)
.map_err(|err| format!("failed to migrate page entry: {}", err))?;
}
meta
.insert("pages".to_string(), Value::Array(pages.clone()))
.map_err(|err| format!("failed to assign pages array: {}", err))?;
return Ok(pages);
}
let pages = doc
.create_array()
.map_err(|err| format!("failed to create pages array: {}", err))?;
meta
.insert("pages".to_string(), Value::Array(pages.clone()))
.map_err(|err| format!("failed to assign pages array: {}", err))?;
Ok(pages)
}
fn any_to_value(doc: &Doc, any: Any) -> Result<Value, String> {
match any {
Any::Array(values) => {
let mut array = doc
.create_array()
.map_err(|err| format!("failed to create nested array: {}", err))?;
for value in values {
let nested = any_to_value(doc, value)?;
array
.push(nested)
.map_err(|err| format!("failed to push nested array value: {}", err))?;
}
Ok(Value::Array(array))
}
Any::Object(values) => {
let mut map = doc
.create_map()
.map_err(|err| format!("failed to create nested map: {}", err))?;
for (key, value) in values {
let nested = any_to_value(doc, value)?;
map
.insert(key, nested)
.map_err(|err| format!("failed to insert nested map value: {}", err))?;
}
Ok(Value::Map(map))
}
_ => Ok(Value::Any(any)),
}
}
fn extract_meta_from_page_map(page_map: &Map, doc_id: Option<String>) -> FrontmatterMeta {
let mut meta = FrontmatterMeta::default();
meta.id = doc_id.or_else(|| get_string_from_map(page_map, "id"));
meta.title = get_string_from_map(page_map, "title");
if let Some(tags) = page_map.get("tags") {
meta.tags = extract_tags_from_value(&tags);
}
meta.favorite = page_map
.get("favorite")
.and_then(|value| value.to_any())
.and_then(|value| any_to_bool(&value));
meta.trash = page_map
.get("trash")
.and_then(|value| value.to_any())
.and_then(|value| any_to_bool(&value));
meta
}
fn extract_tags_from_value(value: &Value) -> Option<Vec<String>> {
if let Some(array) = value.to_array() {
let mut tags = Vec::new();
for item in array.iter() {
if let Some(any) = item.to_any()
&& let Some(value) = any_to_string(&any)
{
tags.push(value);
}
}
return Some(tags);
}
value.to_any().and_then(|any| extract_tags_from_any(&any))
}
fn extract_tags_from_any(value: &Any) -> Option<Vec<String>> {
match value {
Any::Array(values) => {
let mut tags = Vec::new();
for value in values {
if let Some(tag) = any_to_string(value) {
tags.push(tag);
}
}
Some(tags)
}
Any::String(value) => Some(parse_tags(value)),
_ => None,
}
}
fn any_to_bool(value: &Any) -> Option<bool> {
match value {
Any::True => Some(true),
Any::False => Some(false),
Any::Integer(value) => Some(*value != 0),
Any::BigInt64(value) => Some(*value != 0),
Any::Float32(value) => Some(value.0 != 0.0),
Any::Float64(value) => Some(value.0 != 0.0),
Any::String(value) => parse_bool(value),
Any::Null | Any::Undefined => None,
Any::Array(_) | Any::Object(_) | Any::Binary(_) => Some(true),
}
}
fn any_to_string(value: &Any) -> Option<String> {
match value {
Any::String(value) => Some(value.to_string()),
Any::Integer(value) => Some(value.to_string()),
Any::BigInt64(value) => Some(value.to_string()),
Any::Float32(value) => Some(value.0.to_string()),
Any::Float64(value) => Some(value.0.to_string()),
Any::True => Some("true".to_string()),
Any::False => Some("false".to_string()),
Any::Null | Any::Undefined => None,
Any::Array(_) | Any::Object(_) | Any::Binary(_) => None,
}
}
fn get_string_from_map(map: &Map, key: &str) -> Option<String> {
map.get(key).and_then(|value| {
if let Some(text) = value.to_text() {
return Some(text.to_string());
}
value.to_any().and_then(|any| any_to_string(&any))
})
}
fn has_numeric_timestamp(page: &Map, key: &str) -> bool {
page
.get(key)
.and_then(|value| value.to_any())
.is_some_and(|value| match value {
Any::Integer(_) | Any::BigInt64(_) => true,
Any::Float32(value) => value.0.is_finite(),
Any::Float64(value) => value.0.is_finite(),
_ => false,
})
}
@@ -0,0 +1,869 @@
use std::{
collections::{HashMap, HashSet, VecDeque},
fs,
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use affine_common::doc_parser::{build_full_doc, parse_doc_to_markdown, update_doc};
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
use tokio::{sync::Mutex, task::JoinHandle};
use super::{
DiskDocClock, DiskDocUpdateInput, DiskSessionOptions, DiskSyncDocUpdateEvent, DiskSyncEvent,
frontmatter::{normalize_tags, parse_frontmatter, render_frontmatter},
root_meta::{build_root_meta_update, extract_all_root_meta, extract_root_meta_for_doc},
state_db::StateDb,
types::{Baseline, FrontmatterMeta},
utils::{
collect_markdown_files, derive_title_from_markdown, derive_title_from_path, generate_missing_doc_id, hash_meta,
hash_string, is_empty_update, merge_update_binary, now_naive, paths_equal, sanitize_file_stem, write_atomic,
},
};
#[derive(Clone)]
pub(crate) struct DiskSession {
workspace_id: String,
sync_folder: PathBuf,
state_db: StateDb,
events: Arc<Mutex<VecDeque<DiskSyncEvent>>>,
docs: Arc<Mutex<HashMap<String, Vec<u8>>>>,
root_doc: Arc<Mutex<Vec<u8>>>,
bindings: Arc<Mutex<HashMap<String, PathBuf>>>,
path_bindings: Arc<Mutex<HashMap<PathBuf, String>>>,
baselines: Arc<Mutex<HashMap<String, Baseline>>>,
missing_logged: Arc<Mutex<HashSet<PathBuf>>>,
last_sync: Arc<Mutex<HashMap<String, chrono::NaiveDateTime>>>,
last_error: Arc<Mutex<Option<String>>>,
subscribers: Arc<Mutex<HashMap<u64, Arc<ThreadsafeFunction<DiskSyncEvent, ()>>>>>,
poll_task: Arc<Mutex<Option<JoinHandle<()>>>>,
closed: Arc<AtomicBool>,
scan_guard: Arc<Mutex<()>>,
}
impl DiskSession {
pub(crate) async fn new(options: DiskSessionOptions) -> Result<Self, String> {
let sync_folder = PathBuf::from(&options.sync_folder);
fs::create_dir_all(&sync_folder)
.map_err(|err| format!("failed to create sync folder {}: {}", sync_folder.display(), err))?;
let state_db = StateDb::open(&sync_folder, &options.workspace_id).await?;
let bindings = state_db.load_bindings().await?;
let baselines = state_db.load_baselines().await?;
let mut path_bindings = HashMap::new();
for (doc_id, file_path) in &bindings {
path_bindings.insert(file_path.clone(), doc_id.clone());
}
Ok(Self {
workspace_id: options.workspace_id,
sync_folder,
state_db,
events: Arc::new(Mutex::new(VecDeque::new())),
docs: Arc::new(Mutex::new(HashMap::new())),
root_doc: Arc::new(Mutex::new(Vec::new())),
bindings: Arc::new(Mutex::new(bindings)),
path_bindings: Arc::new(Mutex::new(path_bindings)),
baselines: Arc::new(Mutex::new(baselines)),
missing_logged: Arc::new(Mutex::new(HashSet::new())),
last_sync: Arc::new(Mutex::new(HashMap::new())),
last_error: Arc::new(Mutex::new(None)),
subscribers: Arc::new(Mutex::new(HashMap::new())),
poll_task: Arc::new(Mutex::new(None)),
closed: Arc::new(AtomicBool::new(false)),
scan_guard: Arc::new(Mutex::new(())),
})
}
pub(crate) async fn close(&self) {
self.closed.store(true, Ordering::Relaxed);
self.stop_poll_task().await;
self.subscribers.lock().await.clear();
self.state_db.close().await;
}
pub(crate) async fn add_subscriber(
&self,
subscriber_id: u64,
callback: ThreadsafeFunction<DiskSyncEvent, ()>,
) -> Result<(), String> {
let callback = Arc::new(callback);
let backlog = {
let mut events = self.events.lock().await;
events.drain(..).collect::<Vec<_>>()
};
{
let mut subscribers = self.subscribers.lock().await;
subscribers.insert(subscriber_id, callback.clone());
}
for event in backlog {
let _ = callback.call(Ok(event), ThreadsafeFunctionCallMode::NonBlocking);
}
self.ensure_poll_task().await;
Ok(())
}
pub(crate) async fn remove_subscriber(&self, subscriber_id: u64) {
let should_stop = {
let mut subscribers = self.subscribers.lock().await;
subscribers.remove(&subscriber_id);
subscribers.is_empty()
};
if should_stop {
self.stop_poll_task().await;
}
}
async fn ensure_poll_task(&self) {
if self.closed.load(Ordering::Relaxed) {
return;
}
let has_subscribers = {
let subscribers = self.subscribers.lock().await;
!subscribers.is_empty()
};
if !has_subscribers {
return;
}
let mut poll_task = self.poll_task.lock().await;
if poll_task.is_some() {
return;
}
let poll_interval_ms = std::env::var("AFFINE_DISK_POLL_INTERVAL_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value > 0)
.unwrap_or(500);
let session = self.clone();
*poll_task = Some(tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(poll_interval_ms));
loop {
interval.tick().await;
if session.closed.load(Ordering::Relaxed) {
break;
}
let has_subscribers = {
let subscribers = session.subscribers.lock().await;
!subscribers.is_empty()
};
if !has_subscribers {
break;
}
if let Err(err) = session.scan_once().await {
session.queue_error_event(err).await;
}
}
}));
}
async fn stop_poll_task(&self) {
let mut poll_task = self.poll_task.lock().await;
if let Some(handle) = poll_task.take() {
handle.abort();
}
}
pub(crate) async fn queue_ready_event(&self) -> Result<(), String> {
self
.emit_event(DiskSyncEvent {
r#type: "ready".to_string(),
update: None,
doc_id: None,
timestamp: None,
origin: None,
message: None,
})
.await;
Ok(())
}
async fn emit_event(&self, event: DiskSyncEvent) {
let subscribers = {
let subscribers = self.subscribers.lock().await;
subscribers.values().cloned().collect::<Vec<_>>()
};
if subscribers.is_empty() {
let mut events = self.events.lock().await;
events.push_back(event);
return;
}
for callback in subscribers {
let _ = callback.call(Ok(event.clone()), ThreadsafeFunctionCallMode::NonBlocking);
}
}
async fn queue_error_event(&self, message: impl Into<String>) {
let message = message.into();
{
let mut last_error = self.last_error.lock().await;
*last_error = Some(message.clone());
}
self
.emit_event(DiskSyncEvent {
r#type: "error".to_string(),
update: None,
doc_id: None,
timestamp: Some(now_naive()),
origin: None,
message: Some(message),
})
.await;
}
async fn queue_doc_update_event(&self, update: DiskSyncDocUpdateEvent, origin: Option<String>) {
self
.emit_event(DiskSyncEvent {
r#type: "doc-update".to_string(),
doc_id: Some(update.doc_id.clone()),
timestamp: Some(update.timestamp),
update: Some(update),
origin,
message: None,
})
.await;
}
pub(crate) async fn pull_events(&self) -> Result<Vec<DiskSyncEvent>, String> {
if let Err(err) = self.scan_once().await {
self.queue_error_event(err).await;
}
let mut events = self.events.lock().await;
let mut drained = Vec::with_capacity(events.len());
while let Some(event) = events.pop_front() {
drained.push(event);
}
Ok(drained)
}
pub(crate) async fn scan_once(&self) -> Result<(), String> {
let _guard = self.scan_guard.lock().await;
let mut markdown_files = Vec::new();
collect_markdown_files(&self.sync_folder, &mut markdown_files)?;
let mut seen_paths = HashSet::new();
for file_path in markdown_files {
seen_paths.insert(file_path.clone());
if let Err(err) = self.import_file_if_changed(&file_path).await {
self.state_db.append_event(None, "import-error", &err).await.ok();
self.queue_error_event(err).await;
}
}
self.handle_missing_files(&seen_paths).await?;
Ok(())
}
async fn handle_missing_files(&self, seen_paths: &HashSet<PathBuf>) -> Result<(), String> {
let path_bindings = self.path_bindings.lock().await.clone();
let mut missing_logged = self.missing_logged.lock().await;
for (path, doc_id) in path_bindings {
if seen_paths.contains(&path) {
missing_logged.remove(&path);
continue;
}
if missing_logged.contains(&path) {
continue;
}
missing_logged.insert(path.clone());
self
.state_db
.append_event(Some(&doc_id), "file-missing", &path.to_string_lossy())
.await?;
}
Ok(())
}
async fn import_file_if_changed(&self, file_path: &Path) -> Result<(), String> {
let raw = fs::read_to_string(file_path)
.map_err(|err| format!("failed to read markdown file {}: {}", file_path.display(), err))?;
let (mut meta, body) = parse_frontmatter(&raw);
let mut doc_id = meta.id.clone();
if doc_id.is_none() {
doc_id = Some(generate_missing_doc_id(file_path));
meta.id = doc_id.clone();
let rendered = render_frontmatter(&meta, &body);
write_atomic(file_path, &rendered)?;
}
let doc_id = doc_id.ok_or_else(|| format!("failed to resolve doc id for markdown file {}", file_path.display()))?;
let title = meta
.title
.clone()
.or_else(|| derive_title_from_markdown(&body))
.unwrap_or_else(|| derive_title_from_path(file_path));
let normalized_meta = FrontmatterMeta {
id: Some(doc_id.clone()),
title: Some(title),
tags: normalize_tags(meta.tags.clone()),
favorite: Some(meta.favorite.unwrap_or(false)),
trash: Some(meta.trash.unwrap_or(false)),
};
let md_hash = hash_string(&body);
let meta_hash = hash_meta(&normalized_meta);
let baseline = {
let baselines = self.baselines.lock().await;
baselines.get(&doc_id).cloned()
};
let current_binding = {
let bindings = self.bindings.lock().await;
bindings.get(&doc_id).cloned()
};
let unchanged = baseline
.as_ref()
.zip(current_binding.as_ref())
.map(|(baseline, bound_path)| {
baseline.md_hash == md_hash && baseline.meta_hash == meta_hash && paths_equal(bound_path, file_path)
})
.unwrap_or(false);
if unchanged {
return Ok(());
}
let (page_update, full_doc) = {
let docs = self.docs.lock().await;
let maybe_existing = docs.get(&doc_id).cloned();
match maybe_existing {
Some(existing_bin) if !is_empty_update(&existing_bin) => {
let delta = update_doc(&existing_bin, &body, &doc_id)
.map_err(|err| format!("failed to update doc from markdown {}: {}", doc_id, err))?;
let merged = merge_update_binary(Some(&existing_bin), &delta, Some(&doc_id))?;
(delta, merged)
}
_ => {
let built = build_full_doc(normalized_meta.title.as_deref().unwrap_or("Untitled"), &body, &doc_id)
.map_err(|err| format!("failed to build doc from markdown {}: {}", doc_id, err))?;
(built.clone(), built)
}
}
};
{
let mut docs = self.docs.lock().await;
docs.insert(doc_id.clone(), full_doc);
}
let now = now_naive();
self
.queue_doc_update_event(
DiskSyncDocUpdateEvent {
doc_id: doc_id.clone(),
bin: page_update.into(),
timestamp: now,
editor: None,
},
Some("disk:file-import".to_string()),
)
.await;
self.apply_root_meta_from_file(&doc_id, &normalized_meta, now).await?;
{
let mut bindings = self.bindings.lock().await;
let mut path_bindings = self.path_bindings.lock().await;
if let Some(prev) = bindings.insert(doc_id.clone(), file_path.to_path_buf()) {
path_bindings.remove(&prev);
}
path_bindings.insert(file_path.to_path_buf(), doc_id.clone());
}
self.state_db.upsert_binding(&doc_id, file_path).await.map_err(|err| {
format!(
"failed to persist binding for doc {} path {}: {}",
doc_id,
file_path.display(),
err
)
})?;
let baseline = Baseline {
base_clock: String::new(),
base_vector: String::new(),
md_hash,
meta_hash,
synced_at: now,
};
{
let mut baselines = self.baselines.lock().await;
baselines.insert(doc_id.clone(), baseline.clone());
}
self
.state_db
.upsert_baseline(&doc_id, &baseline)
.await
.map_err(|err| format!("failed to persist baseline for doc {}: {}", doc_id, err))?;
{
let mut last_sync = self.last_sync.lock().await;
last_sync.insert(doc_id.clone(), now);
}
self
.state_db
.append_event(Some(&doc_id), "import", &file_path.to_string_lossy())
.await?;
Ok(())
}
async fn apply_root_meta_from_file(
&self,
doc_id: &str,
meta: &FrontmatterMeta,
timestamp: chrono::NaiveDateTime,
) -> Result<(), String> {
let current_root = self.root_doc.lock().await.clone();
let delta = build_root_meta_update(&current_root, &self.workspace_id, doc_id, meta)?;
if is_empty_update(&delta) {
return Ok(());
}
let merged = merge_update_binary(Some(&current_root), &delta, Some(&self.workspace_id))?;
{
let mut root = self.root_doc.lock().await;
*root = merged;
}
self
.queue_doc_update_event(
DiskSyncDocUpdateEvent {
doc_id: self.workspace_id.clone(),
bin: delta.into(),
timestamp,
editor: None,
},
Some("disk:file-meta".to_string()),
)
.await;
Ok(())
}
pub(crate) async fn apply_local_update(
&self,
update: DiskDocUpdateInput,
origin: Option<String>,
) -> Result<DiskDocClock, String> {
// Serialize local updates with filesystem scanning/importing.
//
// Without this guard, root-meta exports and page exports can run concurrently
// and race on the same markdown file/baseline, causing the file content to
// flip between different snapshots while the client is editing.
let _guard = self.scan_guard.lock().await;
let timestamp = now_naive();
if update.doc_id == self.workspace_id {
self
.apply_local_root_update(update.bin.as_ref().to_vec(), timestamp, origin)
.await?;
return Ok(DiskDocClock {
doc_id: update.doc_id,
timestamp,
});
}
self
.apply_local_page_update(
update.doc_id.clone(),
update.bin.as_ref().to_vec(),
update.editor,
timestamp,
)
.await?;
Ok(DiskDocClock {
doc_id: update.doc_id,
timestamp,
})
}
async fn apply_local_root_update(
&self,
update_bin: Vec<u8>,
timestamp: chrono::NaiveDateTime,
_origin: Option<String>,
) -> Result<(), String> {
let current_root = self.root_doc.lock().await.clone();
let merged_root = merge_update_binary(Some(&current_root), &update_bin, Some(&self.workspace_id))?;
{
let mut root = self.root_doc.lock().await;
*root = merged_root.clone();
}
let metas = extract_all_root_meta(&merged_root)?;
for (doc_id, meta) in metas {
let binding_path = {
let bindings = self.bindings.lock().await;
bindings.get(&doc_id).cloned()
};
let doc_body = if let Some(doc_bin) = self.docs.lock().await.get(&doc_id).cloned() {
parse_doc_to_markdown(doc_bin, doc_id.clone(), true, None)
.ok()
.map(|result| result.markdown)
} else {
None
};
let (body, body_from_doc) = if let Some(body) = doc_body {
(body, true)
} else if let Some(path) = binding_path.as_ref().filter(|path| path.exists()) {
let existing = fs::read_to_string(path).map_err(|err| {
format!(
"failed to read markdown for metadata update {}: {}",
path.display(),
err
)
})?;
let (_, body) = parse_frontmatter(&existing);
(body, false)
} else {
continue;
};
if !body_from_doc && body.trim().is_empty() {
continue;
}
let path = if let Some(path) = binding_path {
path
} else {
self.resolve_file_path(&doc_id, meta.title.as_deref()).await?
};
// Avoid overwriting local filesystem edits that haven't been imported yet.
if self.is_markdown_dirty(&doc_id, &path).await {
self
.state_db
.append_event(Some(&doc_id), "export-root-skip-dirty", &path.to_string_lossy())
.await
.ok();
continue;
}
let meta_with_id = meta.clone().with_id(doc_id.clone());
let rendered = render_frontmatter(&meta_with_id, &body);
write_atomic(&path, &rendered)?;
let baseline = Baseline {
base_clock: String::new(),
base_vector: String::new(),
md_hash: hash_string(&body),
meta_hash: hash_meta(&meta_with_id),
synced_at: timestamp,
};
{
let mut baselines = self.baselines.lock().await;
baselines.insert(doc_id.clone(), baseline.clone());
}
self.state_db.upsert_baseline(&doc_id, &baseline).await?;
}
self
.state_db
.append_event(None, "export-root-meta", "root metadata update applied")
.await?;
Ok(())
}
async fn apply_local_page_update(
&self,
doc_id: String,
update_bin: Vec<u8>,
editor: Option<String>,
timestamp: chrono::NaiveDateTime,
) -> Result<(), String> {
// Internal docs (e.g. `db$folders`) are not page documents and are not
// exportable to markdown. Avoid emitting noisy parser errors for them.
if doc_id.starts_with("db$") {
self
.state_db
.append_event(
Some(&doc_id),
"export-skip-internal",
"internal doc skipped (not a page)",
)
.await
.ok();
return Ok(());
}
let current_doc = {
let docs = self.docs.lock().await;
docs.get(&doc_id).cloned()
};
let merged_doc = match merge_update_binary(current_doc.as_deref(), &update_bin, Some(&doc_id)) {
Ok(merged_doc) => merged_doc,
Err(err) => {
// A single malformed document update must not break the whole sync loop.
// Otherwise every push retries globally and delays other documents.
if current_doc.is_some() && err.contains("failed to apply existing update") {
{
let mut docs = self.docs.lock().await;
docs.remove(&doc_id);
}
self
.state_db
.append_event(Some(&doc_id), "export-recover-reset-doc-cache", &err)
.await
.ok();
match merge_update_binary(None, &update_bin, Some(&doc_id)) {
Ok(recovered) => recovered,
Err(recover_err) => {
self
.state_db
.append_event(
Some(&doc_id),
"export-skip-invalid-update",
&format!("{err}; recover failed: {recover_err}"),
)
.await
.ok();
return Ok(());
}
}
} else {
self
.state_db
.append_event(Some(&doc_id), "export-skip-invalid-update", &err)
.await
.ok();
return Ok(());
}
}
};
{
let mut docs = self.docs.lock().await;
docs.insert(doc_id.clone(), merged_doc.clone());
}
let markdown = match parse_doc_to_markdown(merged_doc, doc_id.clone(), true, None) {
Ok(markdown) => markdown,
Err(err) => {
self
.state_db
.append_event(
Some(&doc_id),
"export-skip",
&format!("failed to convert doc {} to markdown: {}", doc_id, err),
)
.await?;
return Ok(());
}
};
let meta = self.meta_for_doc(&doc_id, Some(markdown.title)).await?;
let file_path = self.resolve_file_path(&doc_id, meta.title.as_deref()).await?;
// Avoid overwriting local filesystem edits that haven't been imported yet.
// This is especially important when multiple export passes happen (e.g. page
// update + root meta update) and users edit the markdown file in between
// them.
if self.is_markdown_dirty(&doc_id, &file_path).await {
self
.state_db
.append_event(Some(&doc_id), "export-skip-dirty", &file_path.to_string_lossy())
.await
.ok();
let _ = editor;
return Ok(());
}
let meta_with_id = meta.clone().with_id(doc_id.clone());
let rendered = render_frontmatter(&meta_with_id, &markdown.markdown);
write_atomic(&file_path, &rendered)?;
let baseline = Baseline {
base_clock: String::new(),
base_vector: String::new(),
md_hash: hash_string(&markdown.markdown),
meta_hash: hash_meta(&meta_with_id),
synced_at: timestamp,
};
{
let mut baselines = self.baselines.lock().await;
baselines.insert(doc_id.clone(), baseline.clone());
}
self.state_db.upsert_baseline(&doc_id, &baseline).await?;
{
let mut last_sync = self.last_sync.lock().await;
last_sync.insert(doc_id.clone(), timestamp);
}
self
.state_db
.append_event(Some(&doc_id), "export-page", &file_path.to_string_lossy())
.await?;
let _ = editor;
Ok(())
}
async fn is_markdown_dirty(&self, doc_id: &str, file_path: &Path) -> bool {
if !file_path.exists() {
return false;
}
// No baseline means the file is not tracked by this session yet.
// If it already exists, treat it as dirty and let the import path handle it.
let baseline = {
let baselines = self.baselines.lock().await;
baselines.get(doc_id).cloned()
};
let Some(baseline) = baseline else {
return true;
};
let raw = match fs::read_to_string(file_path) {
Ok(raw) => raw,
Err(err) => {
self
.state_db
.append_event(
Some(doc_id),
"export-skip-read-error",
&format!("{}: {}", file_path.display(), err),
)
.await
.ok();
return true;
}
};
let (meta, body) = parse_frontmatter(&raw);
let title = meta
.title
.clone()
.or_else(|| derive_title_from_markdown(&body))
.unwrap_or_else(|| derive_title_from_path(file_path));
let normalized_meta = FrontmatterMeta {
id: meta.id.clone().or_else(|| Some(doc_id.to_string())),
title: Some(title),
tags: normalize_tags(meta.tags.clone()),
favorite: Some(meta.favorite.unwrap_or(false)),
trash: Some(meta.trash.unwrap_or(false)),
};
let md_hash = hash_string(&body);
let meta_hash = hash_meta(&normalized_meta);
baseline.md_hash != md_hash || baseline.meta_hash != meta_hash
}
async fn meta_for_doc(&self, doc_id: &str, fallback_title: Option<String>) -> Result<FrontmatterMeta, String> {
let root = self.root_doc.lock().await.clone();
let mut meta = extract_root_meta_for_doc(&root, doc_id)?.unwrap_or_default();
if meta.title.is_none() {
meta.title = fallback_title;
}
if meta.tags.is_none() {
meta.tags = Some(Vec::new());
}
if meta.favorite.is_none() {
meta.favorite = Some(false);
}
if meta.trash.is_none() {
meta.trash = Some(false);
}
Ok(meta)
}
async fn resolve_file_path(&self, doc_id: &str, title_hint: Option<&str>) -> Result<PathBuf, String> {
if let Some(path) = self.bindings.lock().await.get(doc_id).cloned() {
return Ok(path);
}
let base_name = sanitize_file_stem(title_hint.unwrap_or(doc_id));
let mut index = 1usize;
loop {
let candidate_name = if index == 1 {
format!("{}.md", base_name)
} else {
format!("{}-{}.md", base_name, index)
};
let candidate = self.sync_folder.join(candidate_name);
let taken = {
let path_bindings = self.path_bindings.lock().await;
path_bindings.get(&candidate).cloned()
};
if let Some(existing_doc_id) = taken {
if existing_doc_id == doc_id {
return Ok(candidate);
}
index += 1;
continue;
}
{
let mut bindings = self.bindings.lock().await;
bindings.insert(doc_id.to_string(), candidate.clone());
}
{
let mut path_bindings = self.path_bindings.lock().await;
path_bindings.insert(candidate.clone(), doc_id.to_string());
}
self.state_db.upsert_binding(doc_id, &candidate).await?;
return Ok(candidate);
}
}
}
@@ -0,0 +1,261 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
use sqlx::{
Pool, Row, Sqlite,
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
};
use super::{types::Baseline, utils::now_naive};
#[derive(Clone)]
pub(crate) struct StateDb {
workspace_id: String,
pool: Pool<Sqlite>,
}
impl StateDb {
pub(crate) async fn open(sync_folder: &Path, workspace_id: &str) -> Result<Self, String> {
let state_dir = sync_folder.join(".affine-sync");
fs::create_dir_all(&state_dir)
.map_err(|err| format!("failed to create state dir {}: {}", state_dir.display(), err))?;
let db_path = state_dir.join("state.db");
let connect_options = SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(connect_options)
.await
.map_err(|err| format!("failed to open state db {}: {}", db_path.display(), err))?;
let db = Self {
workspace_id: workspace_id.to_string(),
pool,
};
db.init().await?;
Ok(db)
}
async fn init(&self) -> Result<(), String> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY
);
"#,
)
.execute(&self.pool)
.await
.map_err(|err| format!("failed to create schema_version table: {}", err))?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS bindings (
workspace_id TEXT NOT NULL,
doc_id TEXT NOT NULL,
file_path TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL,
PRIMARY KEY(workspace_id, doc_id)
);
"#,
)
.execute(&self.pool)
.await
.map_err(|err| format!("failed to create bindings table: {}", err))?;
sqlx::query(
r#"
CREATE UNIQUE INDEX IF NOT EXISTS idx_bindings_workspace_file
ON bindings(workspace_id, file_path);
"#,
)
.execute(&self.pool)
.await
.map_err(|err| format!("failed to create bindings index: {}", err))?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS baselines (
workspace_id TEXT NOT NULL,
doc_id TEXT NOT NULL,
base_clock TEXT NOT NULL,
base_vector TEXT NOT NULL,
md_hash TEXT NOT NULL,
meta_hash TEXT NOT NULL,
synced_at DATETIME NOT NULL,
PRIMARY KEY(workspace_id, doc_id)
);
"#,
)
.execute(&self.pool)
.await
.map_err(|err| format!("failed to create baselines table: {}", err))?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL,
doc_id TEXT,
kind TEXT NOT NULL,
ts DATETIME NOT NULL,
payload TEXT NOT NULL
);
"#,
)
.execute(&self.pool)
.await
.map_err(|err| format!("failed to create events table: {}", err))?;
sqlx::query("INSERT OR IGNORE INTO schema_version(version) VALUES (1);")
.execute(&self.pool)
.await
.map_err(|err| format!("failed to initialize schema_version: {}", err))?;
Ok(())
}
pub(crate) async fn load_bindings(&self) -> Result<HashMap<String, PathBuf>, String> {
let rows = sqlx::query(
r#"
SELECT doc_id, file_path
FROM bindings
WHERE workspace_id = ? AND enabled = 1;
"#,
)
.bind(&self.workspace_id)
.fetch_all(&self.pool)
.await
.map_err(|err| format!("failed to load bindings: {}", err))?;
let mut map = HashMap::new();
for row in rows {
let doc_id: String = row.get("doc_id");
let file_path: String = row.get("file_path");
map.insert(doc_id, PathBuf::from(file_path));
}
Ok(map)
}
pub(crate) async fn upsert_binding(&self, doc_id: &str, file_path: &Path) -> Result<(), String> {
sqlx::query(
r#"
INSERT INTO bindings (workspace_id, doc_id, file_path, enabled, updated_at)
VALUES (?, ?, ?, 1, ?)
ON CONFLICT(workspace_id, doc_id)
DO UPDATE SET
file_path = excluded.file_path,
enabled = 1,
updated_at = excluded.updated_at;
"#,
)
.bind(&self.workspace_id)
.bind(doc_id)
.bind(file_path.to_string_lossy().to_string())
.bind(now_naive())
.execute(&self.pool)
.await
.map_err(|err| format!("failed to upsert binding for doc {}: {}", doc_id, err))?;
Ok(())
}
pub(crate) async fn load_baselines(&self) -> Result<HashMap<String, Baseline>, String> {
let rows = sqlx::query(
r#"
SELECT doc_id, base_clock, base_vector, md_hash, meta_hash, synced_at
FROM baselines
WHERE workspace_id = ?;
"#,
)
.bind(&self.workspace_id)
.fetch_all(&self.pool)
.await
.map_err(|err| format!("failed to load baselines: {}", err))?;
let mut map = HashMap::new();
for row in rows {
let doc_id: String = row.get("doc_id");
map.insert(
doc_id,
Baseline {
base_clock: row.get("base_clock"),
base_vector: row.get("base_vector"),
md_hash: row.get("md_hash"),
meta_hash: row.get("meta_hash"),
synced_at: row.get("synced_at"),
},
);
}
Ok(map)
}
pub(crate) async fn upsert_baseline(&self, doc_id: &str, baseline: &Baseline) -> Result<(), String> {
sqlx::query(
r#"
INSERT INTO baselines (
workspace_id,
doc_id,
base_clock,
base_vector,
md_hash,
meta_hash,
synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workspace_id, doc_id)
DO UPDATE SET
base_clock = excluded.base_clock,
base_vector = excluded.base_vector,
md_hash = excluded.md_hash,
meta_hash = excluded.meta_hash,
synced_at = excluded.synced_at;
"#,
)
.bind(&self.workspace_id)
.bind(doc_id)
.bind(&baseline.base_clock)
.bind(&baseline.base_vector)
.bind(&baseline.md_hash)
.bind(&baseline.meta_hash)
.bind(baseline.synced_at)
.execute(&self.pool)
.await
.map_err(|err| format!("failed to upsert baseline for doc {}: {}", doc_id, err))?;
Ok(())
}
pub(crate) async fn append_event(&self, doc_id: Option<&str>, kind: &str, payload: &str) -> Result<(), String> {
sqlx::query(
r#"
INSERT INTO events (workspace_id, doc_id, kind, ts, payload)
VALUES (?, ?, ?, ?, ?);
"#,
)
.bind(&self.workspace_id)
.bind(doc_id)
.bind(kind)
.bind(now_naive())
.bind(payload)
.execute(&self.pool)
.await
.map_err(|err| format!("failed to append event {}: {}", kind, err))?;
Ok(())
}
pub(crate) async fn close(&self) {
self.pool.close().await;
}
}
@@ -0,0 +1,818 @@
use std::{
fs,
path::{Path, PathBuf},
};
use affine_common::doc_parser::{build_full_doc, update_doc};
use chrono::Utc;
use napi::bindgen_prelude::Uint8Array;
use uuid::Uuid;
use y_octo::{Any, DocOptions, Value};
use super::{
DiskDocUpdateInput, DiskSessionOptions, DiskSync, frontmatter::parse_frontmatter, root_meta::build_root_meta_update,
types::FrontmatterMeta, utils::collect_markdown_files,
};
fn temp_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"affine-disk-sync-{}-{}-{}",
std::process::id(),
Utc::now().timestamp_nanos_opt().unwrap_or_default(),
Uuid::new_v4()
));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn build_doc_with_unsupported_block(doc_id: &str, title: &str, flavour: &str) -> Vec<u8> {
let doc = DocOptions::new().with_guid(doc_id.to_string()).build();
let mut blocks = doc.get_or_create_map("blocks").expect("create blocks map");
let mut page = doc.create_map().expect("create page block");
page.insert("sys:id".into(), "page").expect("set page id");
page
.insert("sys:flavour".into(), "affine:page")
.expect("set page flavour");
let mut page_children = doc.create_array().expect("create page children");
page_children.push("note").expect("append page child");
page
.insert("sys:children".into(), Value::Array(page_children))
.expect("set page children");
let mut page_title = doc.create_text().expect("create page title");
page_title.insert(0, title).expect("set page title");
page
.insert("prop:title".into(), Value::Text(page_title))
.expect("set page title prop");
blocks
.insert("page".into(), Value::Map(page))
.expect("insert page block");
let mut note = doc.create_map().expect("create note block");
note.insert("sys:id".into(), "note").expect("set note id");
note
.insert("sys:flavour".into(), "affine:note")
.expect("set note flavour");
let mut note_children = doc.create_array().expect("create note children");
note_children.push("unsupported").expect("append unsupported child");
note
.insert("sys:children".into(), Value::Array(note_children))
.expect("set note children");
note
.insert("prop:displayMode".into(), "page")
.expect("set note display mode");
blocks
.insert("note".into(), Value::Map(note))
.expect("insert note block");
let mut unsupported = doc.create_map().expect("create unsupported block");
unsupported
.insert("sys:id".into(), "unsupported")
.expect("set unsupported id");
unsupported
.insert("sys:flavour".into(), flavour)
.expect("set unsupported flavour");
unsupported
.insert(
"sys:children".into(),
Value::Array(doc.create_array().expect("create unsupported children")),
)
.expect("set unsupported children");
blocks
.insert("unsupported".into(), Value::Map(unsupported))
.expect("insert unsupported block");
doc.encode_update_v1().expect("encode unsupported doc")
}
async fn teardown(sync: &DiskSync, session_id: &str, dir: &Path) {
sync.stop_session(session_id.to_string()).await.expect("stop session");
if dir.exists() {
let _ = fs::remove_dir_all(dir);
}
}
fn is_numeric_any(value: &Any) -> bool {
match value {
Any::Integer(_) | Any::BigInt64(_) => true,
Any::Float32(v) => v.0.is_finite(),
Any::Float64(v) => v.0.is_finite(),
_ => false,
}
}
#[test]
fn parse_frontmatter_supported_fields() {
let raw = r#"---
id: doc-1
title: "Demo"
tags:
- alpha
- beta
favorite: true
trash: false
---
# Heading
Body.
"#;
let (meta, body) = parse_frontmatter(raw);
assert_eq!(meta.id.as_deref(), Some("doc-1"));
assert_eq!(meta.title.as_deref(), Some("Demo"));
assert_eq!(meta.tags, Some(vec!["alpha".to_string(), "beta".to_string()]));
assert_eq!(meta.favorite, Some(true));
assert_eq!(meta.trash, Some(false));
assert!(body.contains("# Heading"));
}
#[test]
fn parse_frontmatter_preserves_explicit_empty_title() {
let raw = r#"---
id: doc-empty-title
title: ""
---
Body
"#;
let (meta, _) = parse_frontmatter(raw);
assert_eq!(meta.id.as_deref(), Some("doc-empty-title"));
assert_eq!(meta.title.as_deref(), Some(""));
}
#[tokio::test]
async fn start_session_imports_markdown_and_creates_state_db() {
let dir = temp_dir();
let md_path = dir.join("doc-a.md");
fs::write(
&md_path,
"---\nid: doc-a\ntitle: A\ntags: [one,two]\n---\n\n# A\n\ncontent",
)
.expect("write markdown");
let sync = DiskSync::new();
let session_id = "session-import";
sync
.start_session(
session_id.to_string(),
DiskSessionOptions {
workspace_id: "ws-a".to_string(),
sync_folder: dir.to_string_lossy().to_string(),
},
)
.await
.expect("start session");
let events = sync.pull_events(session_id.to_string()).await.expect("pull events");
assert!(events.iter().any(|event| event.r#type == "ready"));
assert!(events.iter().any(|event| {
event.r#type == "doc-update" && event.update.as_ref().is_some_and(|update| update.doc_id == "doc-a")
}));
assert!(events.iter().any(|event| {
event.r#type == "doc-update" && event.update.as_ref().is_some_and(|update| update.doc_id == "ws-a")
}));
assert!(dir.join(".affine-sync/state.db").exists());
teardown(&sync, session_id, &dir).await;
}
#[tokio::test]
async fn apply_local_update_exports_markdown_even_with_unsupported_block() {
let dir = temp_dir();
let sync = DiskSync::new();
let session_id = "session-export-unsupported";
sync
.start_session(
session_id.to_string(),
DiskSessionOptions {
workspace_id: "ws-export-unsupported".to_string(),
sync_folder: dir.to_string_lossy().to_string(),
},
)
.await
.expect("start session");
let _ = sync.pull_events(session_id.to_string()).await.expect("pull");
let doc_bin = build_doc_with_unsupported_block("doc-unsupported", "Unsupported", "affine:latex");
sync
.apply_local_update(
session_id.to_string(),
DiskDocUpdateInput {
doc_id: "doc-unsupported".to_string(),
bin: Uint8Array::new(doc_bin),
editor: Some("test".to_string()),
},
Some("origin:local".to_string()),
)
.await
.expect("apply local update");
let mut exported_files = Vec::new();
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
assert_eq!(exported_files.len(), 1);
let content = fs::read_to_string(&exported_files[0]).expect("read exported markdown");
assert!(content.contains("id: doc-unsupported"));
assert!(content.contains("unsupported_block_flavour:affine:latex"));
teardown(&sync, session_id, &dir).await;
}
#[tokio::test]
async fn apply_local_update_exports_markdown_with_stable_id() {
let dir = temp_dir();
let sync = DiskSync::new();
let session_id = "session-export";
sync
.start_session(
session_id.to_string(),
DiskSessionOptions {
workspace_id: "ws-export".to_string(),
sync_folder: dir.to_string_lossy().to_string(),
},
)
.await
.expect("start session");
let _ = sync.pull_events(session_id.to_string()).await.expect("pull");
let doc_bin = build_full_doc("Exported", "# Exported\n\nHello", "doc-export").expect("build doc bin");
sync
.apply_local_update(
session_id.to_string(),
DiskDocUpdateInput {
doc_id: "doc-export".to_string(),
bin: Uint8Array::new(doc_bin),
editor: Some("test".to_string()),
},
Some("origin:local".to_string()),
)
.await
.expect("apply local update");
let mut exported_files = Vec::new();
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
assert_eq!(exported_files.len(), 1);
let content = fs::read_to_string(&exported_files[0]).expect("read exported markdown");
assert!(content.contains("id: doc-export"));
assert!(content.contains("# Exported"));
teardown(&sync, session_id, &dir).await;
}
#[tokio::test]
async fn empty_title_export_does_not_trigger_self_import() {
let dir = temp_dir();
let sync = DiskSync::new();
let session_id = "session-empty-title";
sync
.start_session(
session_id.to_string(),
DiskSessionOptions {
workspace_id: "ws-empty-title".to_string(),
sync_folder: dir.to_string_lossy().to_string(),
},
)
.await
.expect("start session");
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
let doc_id = "doc-empty-title";
let doc_bin = build_full_doc("", "", doc_id).expect("build empty-title doc");
sync
.apply_local_update(
session_id.to_string(),
DiskDocUpdateInput {
doc_id: doc_id.to_string(),
bin: Uint8Array::new(doc_bin),
editor: Some("test".to_string()),
},
Some("origin:local".to_string()),
)
.await
.expect("apply local update");
let events = sync
.pull_events(session_id.to_string())
.await
.expect("pull after export");
assert!(!events.iter().any(|event| {
event.r#type == "doc-update"
&& event.origin.as_deref() == Some("disk:file-import")
&& event.update.as_ref().is_some_and(|update| update.doc_id == doc_id)
}));
teardown(&sync, session_id, &dir).await;
}
#[tokio::test]
async fn invalid_local_update_does_not_block_other_docs_exports() {
let dir = temp_dir();
let sync = DiskSync::new();
let session_id = "session-invalid-update";
sync
.start_session(
session_id.to_string(),
DiskSessionOptions {
workspace_id: "ws-invalid-update".to_string(),
sync_folder: dir.to_string_lossy().to_string(),
},
)
.await
.expect("start session");
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
let doc_a_id = "doc-invalid-a";
let doc_a_bin = build_full_doc("A", "# A\n\none", doc_a_id).expect("build doc A");
sync
.apply_local_update(
session_id.to_string(),
DiskDocUpdateInput {
doc_id: doc_a_id.to_string(),
bin: Uint8Array::new(doc_a_bin),
editor: Some("test".to_string()),
},
Some("origin:local".to_string()),
)
.await
.expect("apply doc A update");
let invalid = sync
.apply_local_update(
session_id.to_string(),
DiskDocUpdateInput {
doc_id: doc_a_id.to_string(),
bin: Uint8Array::new(vec![1, 2, 3]),
editor: Some("test".to_string()),
},
Some("origin:local".to_string()),
)
.await;
assert!(invalid.is_ok());
let doc_b_id = "doc-valid-b";
let doc_b_bin = build_full_doc("B", "# B\n\ntwo", doc_b_id).expect("build doc B");
sync
.apply_local_update(
session_id.to_string(),
DiskDocUpdateInput {
doc_id: doc_b_id.to_string(),
bin: Uint8Array::new(doc_b_bin),
editor: Some("test".to_string()),
},
Some("origin:local".to_string()),
)
.await
.expect("apply doc B update");
let mut exported_files = Vec::new();
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
assert!(!exported_files.is_empty());
let mut found_doc_b = false;
for file in exported_files {
let content = fs::read_to_string(file).expect("read markdown");
if content.contains(&format!("id: {doc_b_id}")) && content.contains("two") {
found_doc_b = true;
break;
}
}
assert!(found_doc_b);
teardown(&sync, session_id, &dir).await;
}
#[tokio::test]
async fn apply_local_root_update_skips_metadata_only_placeholder_without_doc_body() {
let dir = temp_dir();
let sync = DiskSync::new();
let session_id = "session-root-meta-export";
let workspace_id = "ws-root-meta-export";
let doc_id = "doc-root-meta";
sync
.start_session(
session_id.to_string(),
DiskSessionOptions {
workspace_id: workspace_id.to_string(),
sync_folder: dir.to_string_lossy().to_string(),
},
)
.await
.expect("start session");
let _ = sync.pull_events(session_id.to_string()).await.expect("pull");
let root_update = build_root_meta_update(
&[],
workspace_id,
doc_id,
&FrontmatterMeta {
id: None,
title: Some("Root Meta Title".to_string()),
tags: Some(vec!["alpha".to_string()]),
favorite: Some(true),
trash: Some(false),
},
)
.expect("build root meta update");
sync
.apply_local_update(
session_id.to_string(),
DiskDocUpdateInput {
doc_id: workspace_id.to_string(),
bin: Uint8Array::new(root_update),
editor: None,
},
Some("origin:root-meta".to_string()),
)
.await
.expect("apply root update");
let mut exported_files = Vec::new();
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
assert_eq!(exported_files.len(), 0);
teardown(&sync, session_id, &dir).await;
}
#[tokio::test]
async fn file_change_after_export_is_imported_into_workspace() {
let dir = temp_dir();
let sync = DiskSync::new();
let session_id = "session-export-import";
sync
.start_session(
session_id.to_string(),
DiskSessionOptions {
workspace_id: "ws-export-import".to_string(),
sync_folder: dir.to_string_lossy().to_string(),
},
)
.await
.expect("start session");
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
let doc_id = "doc-export-import";
let doc_bin = build_full_doc("Export", "# Export\n\none", doc_id).expect("build doc bin");
sync
.apply_local_update(
session_id.to_string(),
DiskDocUpdateInput {
doc_id: doc_id.to_string(),
bin: Uint8Array::new(doc_bin),
editor: Some("test".to_string()),
},
Some("origin:local".to_string()),
)
.await
.expect("apply local update");
let mut exported_files = Vec::new();
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
assert_eq!(exported_files.len(), 1);
let file_path = exported_files[0].clone();
fs::write(
&file_path,
format!(
"---\nid: {doc_id}\ntitle: Export\ntags: [edited]\nfavorite: false\ntrash: false\n---\n\n# Export\n\nchanged"
),
)
.expect("edit exported markdown");
let events = sync
.pull_events(session_id.to_string())
.await
.expect("pull after local file edit");
assert!(events.iter().any(|event| {
event.r#type == "doc-update"
&& event.update.as_ref().is_some_and(|update| update.doc_id == doc_id)
&& event.origin.as_deref() == Some("disk:file-import")
}));
teardown(&sync, session_id, &dir).await;
}
#[tokio::test]
async fn code_block_update_keeps_markdown_exporting() {
let dir = temp_dir();
let sync = DiskSync::new();
let session_id = "session-code-block-export";
sync
.start_session(
session_id.to_string(),
DiskSessionOptions {
workspace_id: "ws-code-block-export".to_string(),
sync_folder: dir.to_string_lossy().to_string(),
},
)
.await
.expect("start session");
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
let doc_id = "doc-code-block";
let initial_doc = build_full_doc("Code", "# Code\n\nbefore", doc_id).expect("build initial doc");
sync
.apply_local_update(
session_id.to_string(),
DiskDocUpdateInput {
doc_id: doc_id.to_string(),
bin: Uint8Array::new(initial_doc.clone()),
editor: Some("test".to_string()),
},
Some("origin:local".to_string()),
)
.await
.expect("apply initial doc");
let mut exported_files = Vec::new();
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
assert_eq!(exported_files.len(), 1);
let file_path = exported_files[0].clone();
let markdown_with_code = "# Code\n\n```js\nconsole.log(1)\n```\n";
let delta_code = update_doc(&initial_doc, markdown_with_code, doc_id).expect("build code block delta");
sync
.apply_local_update(
session_id.to_string(),
DiskDocUpdateInput {
doc_id: doc_id.to_string(),
bin: Uint8Array::new(delta_code.clone()),
editor: Some("test".to_string()),
},
Some("origin:local".to_string()),
)
.await
.expect("apply code block delta");
let mut doc = DocOptions::new().with_guid(doc_id.to_string()).build();
doc
.apply_update_from_binary_v1(&initial_doc)
.expect("apply initial update");
doc.apply_update_from_binary_v1(&delta_code).expect("apply code update");
let merged_after_code = doc.encode_update_v1().expect("encode merged after code");
let markdown_after_code_edit = "# Code\n\n```js\nconsole.log(2)\n```\n\nnext\n";
let delta_after_code_edit =
update_doc(&merged_after_code, markdown_after_code_edit, doc_id).expect("build delta after code edit");
sync
.apply_local_update(
session_id.to_string(),
DiskDocUpdateInput {
doc_id: doc_id.to_string(),
bin: Uint8Array::new(delta_after_code_edit),
editor: Some("test".to_string()),
},
Some("origin:local".to_string()),
)
.await
.expect("apply follow-up delta");
let exported = fs::read_to_string(&file_path).expect("read exported markdown");
assert!(exported.contains("```js"));
assert!(exported.contains("console.log(2)"));
assert!(exported.contains("next"));
teardown(&sync, session_id, &dir).await;
}
#[tokio::test]
async fn file_change_after_start_is_imported_via_pull_events() {
let dir = temp_dir();
let md_path = dir.join("doc-poll.md");
fs::write(&md_path, "---\nid: doc-poll\ntitle: Poll\n---\n\n# Poll\n\none").expect("write initial markdown");
let sync = DiskSync::new();
let session_id = "session-poll";
sync
.start_session(
session_id.to_string(),
DiskSessionOptions {
workspace_id: "ws-poll".to_string(),
sync_folder: dir.to_string_lossy().to_string(),
},
)
.await
.expect("start session");
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
fs::write(&md_path, "---\nid: doc-poll\ntitle: Poll\n---\n\n# Poll\n\ntwo").expect("write changed markdown");
let events = sync
.pull_events(session_id.to_string())
.await
.expect("pull after change");
assert!(events.iter().any(|event| {
event.r#type == "doc-update" && event.update.as_ref().is_some_and(|update| update.doc_id == "doc-poll")
}));
teardown(&sync, session_id, &dir).await;
}
#[tokio::test]
async fn import_without_title_allows_followup_local_export() {
let dir = temp_dir();
let md_path = dir.join("doc-no-title.md");
fs::write(&md_path, "# Imported\n\none").expect("write markdown");
let sync = DiskSync::new();
let session_id = "session-import-no-title";
let workspace_id = "ws-import-no-title";
sync
.start_session(
session_id.to_string(),
DiskSessionOptions {
workspace_id: workspace_id.to_string(),
sync_folder: dir.to_string_lossy().to_string(),
},
)
.await
.expect("start session");
let events = sync.pull_events(session_id.to_string()).await.expect("pull first");
let imported = fs::read_to_string(&md_path).expect("read imported markdown");
let (meta, _) = parse_frontmatter(&imported);
let doc_id = meta.id.expect("doc id should be generated");
let imported_doc_bin = events
.iter()
.find_map(|event| {
event
.update
.as_ref()
.filter(|update| update.doc_id == doc_id)
.map(|update| update.bin.as_ref().to_vec())
})
.expect("imported page update");
let delta = update_doc(&imported_doc_bin, "# Imported\n\ntwo", &doc_id).expect("build local edit delta");
sync
.apply_local_update(
session_id.to_string(),
DiskDocUpdateInput {
doc_id: doc_id.clone(),
bin: Uint8Array::new(delta),
editor: Some("test".to_string()),
},
Some("origin:local".to_string()),
)
.await
.expect("apply local update");
let updated = fs::read_to_string(&md_path).expect("read markdown after local edit");
assert!(updated.contains("two"));
teardown(&sync, session_id, &dir).await;
}
#[tokio::test]
async fn import_sets_root_meta_create_and_updated_date() {
let dir = temp_dir();
let md_path = dir.join("doc-dates.md");
fs::write(&md_path, "# Dates\n\ncontent").expect("write markdown");
let sync = DiskSync::new();
let session_id = "session-import-dates";
let workspace_id = "ws-import-dates";
sync
.start_session(
session_id.to_string(),
DiskSessionOptions {
workspace_id: workspace_id.to_string(),
sync_folder: dir.to_string_lossy().to_string(),
},
)
.await
.expect("start session");
let events = sync.pull_events(session_id.to_string()).await.expect("pull events");
let imported = fs::read_to_string(&md_path).expect("read imported markdown");
let (meta, _) = parse_frontmatter(&imported);
let doc_id = meta.id.expect("doc id should exist");
let root_update = events
.iter()
.find_map(|event| {
event
.update
.as_ref()
.filter(|update| update.doc_id == workspace_id)
.map(|update| update.bin.as_ref().to_vec())
})
.expect("root-meta update");
let mut root = DocOptions::new().with_guid(workspace_id.to_string()).build();
root
.apply_update_from_binary_v1(&root_update)
.expect("apply root-meta update");
let meta_map = root.get_map("meta").expect("meta map");
let pages = meta_map
.get("pages")
.and_then(|value| value.to_array())
.expect("pages array");
let page_map = pages
.iter()
.find_map(|value| {
let page = value.to_map()?;
let id = page
.get("id")
.and_then(|value| value.to_any())
.and_then(|any| match any {
Any::String(value) => Some(value),
_ => None,
})?;
if id == doc_id { Some(page) } else { None }
})
.expect("imported page meta");
let create_date = page_map
.get("createDate")
.and_then(|value| value.to_any())
.expect("createDate should exist");
assert!(is_numeric_any(&create_date));
let updated_date = page_map
.get("updatedDate")
.and_then(|value| value.to_any())
.expect("updatedDate should exist");
assert!(is_numeric_any(&updated_date));
teardown(&sync, session_id, &dir).await;
}
#[tokio::test]
async fn no_delete_policy_does_not_emit_doc_delete() {
let dir = temp_dir();
let md_path = dir.join("doc-delete.md");
fs::write(&md_path, "---\nid: doc-delete\ntitle: Delete\n---\n\n# Delete\n\none").expect("write markdown");
let sync = DiskSync::new();
let session_id = "session-no-delete";
sync
.start_session(
session_id.to_string(),
DiskSessionOptions {
workspace_id: "ws-delete".to_string(),
sync_folder: dir.to_string_lossy().to_string(),
},
)
.await
.expect("start session");
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
fs::remove_file(&md_path).expect("remove markdown file");
let events = sync
.pull_events(session_id.to_string())
.await
.expect("pull after delete");
assert!(!events.iter().any(|event| event.r#type == "doc-delete"));
teardown(&sync, session_id, &dir).await;
}
@@ -0,0 +1,26 @@
use chrono::NaiveDateTime;
#[derive(Clone)]
pub(crate) struct Baseline {
pub(crate) base_clock: String,
pub(crate) base_vector: String,
pub(crate) md_hash: String,
pub(crate) meta_hash: String,
pub(crate) synced_at: NaiveDateTime,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct FrontmatterMeta {
pub(crate) id: Option<String>,
pub(crate) title: Option<String>,
pub(crate) tags: Option<Vec<String>>,
pub(crate) favorite: Option<bool>,
pub(crate) trash: Option<bool>,
}
impl FrontmatterMeta {
pub(crate) fn with_id(mut self, id: String) -> Self {
self.id = Some(id);
self
}
}
@@ -0,0 +1,250 @@
use std::{
fs,
path::{Path, PathBuf},
};
use chrono::{DateTime, NaiveDateTime, Utc};
use sha3::{Digest, Sha3_256};
use y_octo::{Doc, DocOptions, StateVector};
use super::{frontmatter::normalize_tags, types::FrontmatterMeta};
pub(crate) fn collect_markdown_files(root: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> {
let entries = fs::read_dir(root).map_err(|err| format!("failed to read directory {}: {}", root.display(), err))?;
for entry in entries {
let entry = entry.map_err(|err| format!("failed to read directory entry: {}", err))?;
let path = entry.path();
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == ".affine-sync")
{
continue;
}
if path.is_dir() {
collect_markdown_files(&path, output)?;
continue;
}
if path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
output.push(path);
}
}
Ok(())
}
pub(crate) fn generate_missing_doc_id(file_path: &Path) -> String {
let stem = file_path
.file_stem()
.and_then(|value| value.to_str())
.map(sanitize_file_stem)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "doc".to_string());
format!("{}-{}", stem, Utc::now().timestamp_millis())
}
pub(crate) fn derive_title_from_markdown(markdown: &str) -> Option<String> {
for line in markdown.lines() {
let trimmed = line.trim();
if let Some(title) = trimmed.strip_prefix("# ") {
let title = title.trim();
if !title.is_empty() {
return Some(title.to_string());
}
}
}
None
}
pub(crate) fn derive_title_from_path(file_path: &Path) -> String {
file_path
.file_stem()
.and_then(|value| value.to_str())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "Untitled".to_string())
}
pub(crate) fn sanitize_file_stem(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else if (ch == '-' || ch == '_' || ch == ' ') && !out.ends_with('-') {
out.push('-');
}
}
let out = out.trim_matches('-').to_string();
if out.is_empty() { "doc".to_string() } else { out }
}
pub(crate) fn write_atomic(path: &Path, content: &str) -> Result<(), String> {
let parent = path
.parent()
.ok_or_else(|| format!("path {} has no parent directory", path.display()))?;
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create parent directory {}: {}", parent.display(), err))?;
let temp_name = format!(
".affine-sync-tmp-{}-{}.md",
std::process::id(),
Utc::now().timestamp_millis()
);
let temp_path = parent.join(temp_name);
fs::write(&temp_path, content)
.map_err(|err| format!("failed to write temp file {}: {}", temp_path.display(), err))?;
// On Unix, `rename` replaces the destination atomically. Avoiding an explicit
// delete reduces "delete + create" file events, which can confuse file
// watchers/editors and cause apparent content flapping.
//
// On Windows, `rename` fails if destination exists, so we remove first.
#[cfg(windows)]
{
if path.exists() {
fs::remove_file(path).map_err(|err| format!("failed to replace file {}: {}", path.display(), err))?;
}
}
fs::rename(&temp_path, path).map_err(|err| {
format!(
"failed to move temp file {} to {}: {}",
temp_path.display(),
path.display(),
err
)
})?;
Ok(())
}
pub(crate) fn hash_string(value: &str) -> String {
let mut hasher = Sha3_256::new();
hasher.update(value.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push(hex_char(byte >> 4));
out.push(hex_char(byte & 0x0f));
}
out
}
pub(crate) fn hash_meta(meta: &FrontmatterMeta) -> String {
let mut canonical = String::new();
canonical.push_str("id=");
canonical.push_str(meta.id.as_deref().unwrap_or_default());
canonical.push_str("|title=");
canonical.push_str(meta.title.as_deref().unwrap_or_default());
canonical.push_str("|tags=");
if let Some(tags) = normalize_tags(meta.tags.clone()) {
canonical.push_str(&tags.join("\u{1f}"));
}
canonical.push_str("|favorite=");
canonical.push_str(if meta.favorite.unwrap_or(false) {
"true"
} else {
"false"
});
canonical.push_str("|trash=");
canonical.push_str(if meta.trash.unwrap_or(false) { "true" } else { "false" });
hash_string(&canonical)
}
fn hex_char(value: u8) -> char {
match value {
0..=9 => (b'0' + value) as char,
10..=15 => (b'a' + (value - 10)) as char,
_ => '0',
}
}
pub(crate) fn now_naive() -> NaiveDateTime {
DateTime::from_timestamp_millis(Utc::now().timestamp_millis())
.unwrap_or_else(Utc::now)
.naive_utc()
}
pub(crate) fn is_empty_update(value: &[u8]) -> bool {
value.is_empty() || value == [0, 0]
}
pub(crate) fn merge_update_binary(
existing: Option<&[u8]>,
update: &[u8],
doc_id: Option<&str>,
) -> Result<Vec<u8>, String> {
let mut doc = if let Some(existing) = existing {
if is_empty_update(existing) {
build_doc(doc_id)
} else {
let mut doc = build_doc(doc_id);
doc
.apply_update_from_binary_v1(existing)
.map_err(|err| format!("failed to apply existing update: {}", err))?;
doc
}
} else {
build_doc(doc_id)
};
if !is_empty_update(update) {
doc
.apply_update_from_binary_v1(update)
.map_err(|err| format!("failed to merge update: {}", err))?;
}
doc
.encode_state_as_update_v1(&StateVector::default())
.map_err(|err| format!("failed to encode merged update: {}", err))
}
pub(crate) fn build_doc(doc_id: Option<&str>) -> Doc {
let options = DocOptions::new();
match doc_id {
Some(doc_id) => options.with_guid(doc_id.to_string()).build(),
None => options.build(),
}
}
pub(crate) fn load_doc_or_new(binary: &[u8], doc_id: Option<&str>) -> Result<Doc, String> {
if is_empty_update(binary) {
return Ok(build_doc(doc_id));
}
let mut doc = build_doc(doc_id);
doc
.apply_update_from_binary_v1(binary)
.map_err(|err| format!("failed to decode doc binary: {}", err))?;
Ok(doc)
}
pub(crate) fn paths_equal(lhs: &Path, rhs: &Path) -> bool {
if lhs == rhs {
return true;
}
match (lhs.canonicalize(), rhs.canonicalize()) {
(Ok(lhs), Ok(rhs)) => lhs == rhs,
_ => false,
}
}
+2
View File
@@ -1,3 +1,4 @@
pub mod disk_sync;
pub mod hashcash;
#[cfg(not(target_arch = "arm"))]
@@ -8,3 +9,4 @@ static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
pub use affine_media_capture::*;
pub use affine_nbstore::*;
pub use affine_sqlite_v1::*;
pub use disk_sync::*;