Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/caldav.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
图事务支持共享命名空间锁、Block/Relation/PostgreSQL blob 行锁和一致性读取,保护 DAV 与普通 Peer 之间的条件写入。
6 changes: 4 additions & 2 deletions app/business/info_base/storage/postgresql.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ def serialize_pointer(self, pointer: object) -> str:
blob_id = pydantic.TypeAdapter(uuid.UUID).validate_python(pointer)
return PostgreSQLBlobPointer(blob_id=blob_id).model_dump_json()

async def read_content(self, block_content: str, storage: StorageRepository) -> bytes:
async def read_content(
self, block_content: str, storage: StorageRepository, *, lock: bool = False
) -> bytes:
pointer = PostgreSQLBlobPointer.model_validate_json(block_content)
content = await storage.read_blob(pointer.blob_id)
content = await storage.read_blob(pointer.blob_id, lock=lock)
if content is None:
raise StorageBlobNotFoundError(f"Storage blob {pointer.blob_id} not found")
return content
Expand Down
26 changes: 16 additions & 10 deletions app/persistence/info_base/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,16 @@ async def get_many(self, block_ids: typing.Collection[BlockID]) -> tuple[BlockMo
)

async def get_by_resolvers(
self, resolvers: typing.Collection[str]
self, resolvers: typing.Collection[str], *, lock: bool = False
) -> tuple[BlockModel, ...]:
if not resolvers:
return ()
return tuple(
await self._session.scalars(
sqlmodel.select(BlockModel).where(
sqlmodel.col(BlockModel.resolver).in_(tuple(resolvers))
)
)
statement = sqlmodel.select(BlockModel).where(
sqlmodel.col(BlockModel.resolver).in_(tuple(resolvers))
)
if lock:
statement = statement.order_by(sqlmodel.col(BlockModel.id)).with_for_update()
return tuple(await self._session.scalars(statement))

async def save_many(self, blocks: Iterable[BlockModel]) -> None:
self._session.add_all(blocks)
Expand Down Expand Up @@ -200,11 +199,15 @@ async def find_json_field_many(
)
)

async def acquire_transaction_lock(self, namespace: str) -> None:
async def acquire_transaction_lock(self, namespace: str, *, shared: bool = False) -> None:
"""Serialize an owning use case until its surrounding transaction ends."""
function = (
sqlmodel.func.pg_advisory_xact_lock_shared
if shared
else sqlmodel.func.pg_advisory_xact_lock
)
await self._session.execute(
sqlalchemy.text("SELECT pg_advisory_xact_lock(hashtextextended(:name, 0))"),
{"name": namespace},
sqlmodel.select(function(sqlmodel.func.hashtextextended(namespace, 0)))
)

async def get_related(
Expand Down Expand Up @@ -333,6 +336,7 @@ async def get_outgoing_many(
*,
to_ids: typing.Collection[BlockID] | None = None,
content: str | None = None,
lock: bool = False,
) -> tuple[RelationModel, ...]:
if not block_ids:
return ()
Expand All @@ -343,6 +347,8 @@ async def get_outgoing_many(
statement = statement.where(sqlmodel.col(RelationModel.to_).in_(tuple(to_ids)))
if content is not None:
statement = statement.where(RelationModel.content == content)
if lock:
statement = statement.order_by(sqlmodel.col(RelationModel.id)).with_for_update()
return tuple(await self._session.scalars(statement))

async def save(self, relation: RelationModel) -> None:
Expand Down
11 changes: 5 additions & 6 deletions app/persistence/info_base/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,11 @@ async def create(self, storage: StorageModel) -> StorageModel:
await self._session.flush()
return storage

async def read_blob(self, blob_id: UUID) -> bytes | None:
return (
await self._session.scalars(
sqlmodel.select(StorageBlobModel.data).where(StorageBlobModel.id == blob_id)
)
).one_or_none()
async def read_blob(self, blob_id: UUID, *, lock: bool = False) -> bytes | None:
statement = sqlmodel.select(StorageBlobModel.data).where(StorageBlobModel.id == blob_id)
if lock:
statement = statement.with_for_update()
return (await self._session.scalars(statement)).one_or_none()

async def create_blob(self, content: bytes) -> UUID:
blob = StorageBlobModel(data=content)
Expand Down
6 changes: 5 additions & 1 deletion app/persistence/info_base/uow.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ class GraphUnitOfWork:


@asynccontextmanager
async def graph_uow() -> AsyncGenerator[GraphUnitOfWork, None]:
async def graph_uow(
*, repeatable_read: bool = False
) -> AsyncGenerator[GraphUnitOfWork, None]:
"""Commit on success; roll back on failure and always close the session."""
async with AsyncSessionFactory.begin() as session:
if repeatable_read:
await session.connection(execution_options={"isolation_level": "REPEATABLE READ"})
yield GraphUnitOfWork(
BlockRepository(session), RelationRepository(session), StorageRepository(session)
)
2 changes: 1 addition & 1 deletion app/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path
import tomllib

CORE_VERSION = "0.3.1"
CORE_VERSION = "0.3.3"
APPLICATION_VERSION = tomllib.loads(
(Path(__file__).parents[1] / "pyproject.toml").read_text(encoding="utf-8")
)["project"]["version"]
116 changes: 116 additions & 0 deletions docs/30-unit-tdd/caldav-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# CalDAV / CardDAV 扩展

`inkcre/caldav` 提供共享部署内的日历、任务和联系人读写。Radicale 3.8.3 拥有 DAV
发现、XML、过滤、重复规则和原生内容校验;扩展拥有配置认证、图映射、事务、
Resolver 和 Host 生命周期。协议层不使用 Radicale 文件存储,不另外建立 DAV 数据库。

## 信息权威与复用

| Resolver | Block 内容 | 关联 |
| --- | --- | --- |
| `extensions.caldav.collection.v2` | `path`、DAV `props`、`published` | 出向 `member` Relation 发布资源 |
| `extensions.caldav.calendar.v2` | `href`、`remainder` | 指向独立正文、附件和相关对象 |
| `extensions.caldav.contact.v2` | `href`、`remainder` | 指向独立电话、邮箱、备注、URL 和媒体 |

`remainder` 是剔除图拥有属性后的原生内容。名称、日期、状态、重复规则、时区和未知扩展属性
仍属于对应对象;完整 iCalendar/vCard 由剩余字段与当前 Relation 目标组合生成,不另存可写全文。
事件系列与单次例外保留在同一资源,组件以类型、UID 和完整 RECURRENCE-ID 属性定位,包含值类型
与 TZID;提醒中的独立内容另以 VALARM 顺序定位。

TEL、EMAIL、URL、NOTE、DESCRIPTION 使用普通 `core.text.v1` Block。PHOTO、LOGO、SOUND、ATTACH
的内嵌字节使用既有 PostgreSQL binary Storage 和 `core.image/audio/video/pdf/file.v1` 等语义
resolver;按声明 MIME、字节签名、通用 file 的顺序分类。URI 形式的媒体使用文本 Block 保存原地址,
不自动联网下载。其他能力可以直接读取、处理、引用这些内容,不需要先解释整张名片或日程。

出向 Relation 内容使用 `caldav:property:` 加 JSON,其字段为 `component`、`alarm`、`name`、
`params`、`group`、`position`、`representation`、`media_type`。它独占属性所属组件、协议参数、
标签、顺序和编码方式,目标 Block 独占值。组件内的 position 按属性名分别从零计数;允许删除后
存在空位,但不允许重复位置。存在同名 native 属性与图属性时明确拒绝,不能选一个掩盖冲突。

RELATED-TO 优先指向同集合中 UID 唯一匹配的资源 Block;目标尚不存在或有歧义时指向保存原 UID 的
文本 Block,不猜测实体。后续 DAV 写入和启动迁移会重新解析同集合引用,因此先创建子任务也能在
父任务出现后形成真实 Relation。目标退出集合后,已有引用仍指向该信息对象。

手机更新在当前资源内按组件、属性名、表示方式和值匹配原有目标;只改标签或顺序不重建内容。
不同值创建新的内容并更换 Relation,避免改写其他对象已经引用的旧事实;不按号码或内容在全库
隐式合并对象。其他 Peer 主动编辑共享内容 Block 或其字节则会影响全部引用者,下一次 DAV 读取
直接反映修改。新增或直接修改图的 Peer 必须遵守上述语法及资源路径、UID 的领域约束。

DAV DELETE 只撤销 `member` 发布;删除集合将其 `published` 设为 false,并撤销成员发布。
资源、内容及其他图的引用继续存在,可以在信息库中阅读和复用。此版本不自动回收未被引用的旧值,
也不把撤下同步等同于彻底删除个人信息;需要彻底删除时应由信息库删除命令明确选择对象。

v1 解码器保留。扩展启用时在一个事务内将已有资源提升到 v2,保留根 Block ID、外部关系、路径与
凭据;失败整体回滚。随后新出现的 v1 记录在下一次 DAV 写事务中提升。升级后导出格式可能规范化、
ETag 可能改变,客户端重新扫描;不承诺原始输入字节不变。降级到只理解 v1 的旧分发不受支持。

路径相对于 `/caldav/`,principal 固定为 `inkcre`。集合 token 从当前合成资源及集合属性计算。
未知 token 返回 `403 DAV:valid-sync-token`,客户端须完整重新枚举;相同 token 返回无变化。
这一策略避免删除日志,但放大变化后的读取量。

## 事务与并发

FastAPI 在认证和有界读取请求后,使用 AnyIO 工作线程调用 WSGI。Storage 的
`acquire_lock` 经 `anyio.from_thread.run` 进入同一事件循环上的 graph UoW。
事务期间只有协议计算与数据库操作,没有外部网络调用;响应要等事务提交后才返回。
取消不会丢弃仍运行的线程,异常通过 UoW 回滚。

命名空间 `extensions.caldav.v1` 的 PostgreSQL 事务 advisory lock 在不同 Python
Peer 之间串行化 DAV 写入,并保护还没有行可锁的新集合/资源身份。写事务依次锁定
集合与资源根、相关 Relation、语义 Block 和实际 PostgreSQL blob,并在锁定后读取值。
根与语义 Block 的 FOR UPDATE 阻止并发外键引用插入;Relation 和 blob 行锁覆盖普通 Peer
直接修改关系与字节的路径。ETag 由本次合成的完整协议内容计算,不以根时间戳代表媒体新鲜度。新增集合以及路径/UID 唯一性的
写入仍须遵守该扩展契约和命名空间锁,不能靠任意原始图写入绕过业务校验。

读事务采用 REPEATABLE READ,防止分次读取 Block 和 Relation 时看到不同的已提交状态。
事务内的图快照只负责协议执行,退出写事务时提交差量,不是第二份持久权威。
不增加按资源的事务或后台同步队列;ETag 检查和最终写入在同一个锁范围内。

Radicale 的 PUT 校验当前 ETag;扩展另外在 MOVE 的写锁内校验源 ETag,过期或缺失时返回
412。首次并发发现可能重复请求默认集合创建,Storage 拒绝替换已存在集合,防止覆盖数据。
集合整体覆盖 PUT 被关闭;普通集合属性修改和单资源替换仍受支持。

当前每次事务读取所有 DAV 集合与资源及其属性,包含已撤下发布的对象,写事务锁住这些依赖。对于个人规模,这使事务、
故障恢复和直接图编辑的行为清晰;需要大型通讯录时再引入按集合加载和增量历史,
必须同时保留跨集合移动与删除的并发保证。

## 路由与配置

`Extension.api_dependencies()` 选择协议自有认证。DAV 请求使用 HTTP Basic,
用户名固定为 `inkcre`;每次读取共享 `password` 配置,空值拒绝访问。只有认证后的
principal 通过 WSGI `REMOTE_USER` 传入,客户端不能提供此身份或覆盖 SCRIPT_NAME。
请求体最多 16 MiB,单资源最多 8 MiB;载荷和认证信息不进入协议调试日志。

`caldav.connection.v1` 使用 Peer JWT,只返回公共基址组成的服务地址和用户名。
普通 DAV 凭据不能读取该管理接口。所有路由通过 Extension Host 发布与撤销;
停用不删除图,重新启用不重建已有集合。

Radicale 要求返回 `Last-Modified`,当前使用 DAV 根记录的时间;普通 Peer 编辑关联文本、关系或 blob 不一定改变该时间。它不是完整组合内容的准确修改时间。同步与条件写入必须使用从完整内容计算的 ETag;只依赖日期的客户端未验收,可能漏判共享内容变化。若后续需要支持此类客户端,应先为所有共享依赖建立可可靠聚合的修改时间,不能以读取时刻代替。

## 验证依据

`scripts/tests/caldav/` 保留 codec、图适配与事务排查脚本,不纳入默认测试收集或 CI 门槛,也不替代真机验收。按需运行 `pdm run python -m pytest -q scripts/tests/caldav`;数据库脚本要求通过 `INKCRE_TEST_DATABASE_URL` 显式指定已迁移的隔离 PostgreSQL。该目录的 `conftest.py` 复用既有隔离进程环境,禁止加载开发或 preview 的隐式连接。

这些脚本可辅助检查字段往返、Resolver 读取、ETag 冲突、提交回滚、MOVE、成员并发、凭据撤销,以及共享内容、迁移和引用保留。

2026-10-20 在华为 JKM-AL00a(Android 9)上使用 DAVx⁵ 4.5.14-ose、Tasks.org 15.3
及系统日历/通讯录完成了真实同步验证:重复事件与提醒、离线修改单次例外、任务完成与
恢复、手机新建父子任务、服务端删除、联系人双向修改及照片往返。DAVx⁵ 在旧 token
失效后能够重新同步。服务重启后原生内容和集合 ETag 保持一致。该记录不代表其他客户端
版本或大型集合已经验收;协议集成检查也不能替代 Android 字段映射证据。

同日 PR #146 的 HTTPS preview 通过独立 Registry wheel 选择、进程重启和正常启用流程,
再次验证了上述双向同步、父子引用、照片往返和旧 ETag 的 412 响应。停用返回 404,
重新启用后内容不变;撤销密码返回 401,恢复密码后恢复访问。PostgREST 能读取同一批
原生资源 Block。手机当前网络的域名解析异常,验收使用 DAVx⁵ 应用内 HTTP 代理和
仅允许该 preview 主机的 CONNECT 隧道;TLS 仍由手机与 Heroku 终止。

华为 JKM-AL00a 的系统日历在两种新建流程中都将全天事件的 `eventTimezone` 写成
`java.util.SimpleTimeZone[...]`。DAVx⁵ 因无效 ZoneId 在上传前失败,服务端无法修复
尚未发出的请求。Etar 1.0.57 使用同一 Android 日历存储写入 `UTC`,全天事件已成功
上传为 DATE 类型,并保留正确的排他结束日期。此设备的全天事件创建以 Etar 为验收
基线;系统日历的重复事件显示、单次例外编辑仍有独立通过证据。

2026-10-21 的共享图升级验收使用 Python `342ae9a`(0.2.0)和 Web `fc07fe7`。14 项 DAV 脚本检查覆盖电话重排、共享内容写入、blob 行锁、迁移回滚和删除保留;两分发共用 fixture 检查原生参数、时区、例外与二进制附件。旧 preview 的 9 个根 ID 和 5 份资源内容迁移后不变,共享扩展配置摘要不变。

通过 PostgREST 修改电话 Block #18 和图片 #17 的 blob 后,DAV 正文/ETag 改变,旧 ETag PUT 返回 412;华为联系人显示新号码和头像。手机改名上传时保留电话 #18,将重新编码的照片关联到新图片 #25,原图片 #17 的独立引用和字节摘要不变。Tasks.org 删除测试任务 #22 并同步后 DAV 返回 404,成员关系消失,但根、描述 #23 和外部引用 #24 保留;Web 可继续读取原任务和描述。Web 还实际验证了父任务跳转、独立图片与电话视图、390px 内容阅读及正常向导,截图在两分发教程目录。
8 changes: 6 additions & 2 deletions docs/40-deployment/first-party-extension-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ be disabled. A legacy enabled member migrates to its exact prior version with an
distribution. Cold restore resolves only that exact Registry version and never substitutes the
latest release. Failed resolution preserves the old intent and reports the error.

The seven first-party Extensions are independent PEP 420 wheels with the standard entry-point group
The first-party Extensions are independent PEP 420 wheels with the standard entry-point group
and producer metadata in their `pyproject.toml`. That project version is the Python association's
Release authority and must equal the Extension Release Version shared by its Distribution
associations. Feature pull requests carry project-local Towncrier fragments and leave versions and
Expand All @@ -63,7 +63,7 @@ Repository CI discovers the producer set and rejects missing or invalid project
never runs from the Release PR。

The trusted pull-request preview controller reuses that same discovered producer set. Its dedicated
PDM `extension-preview` tooling group builds and verifies all seven wheels, then the released
PDM `extension-preview` tooling group builds and verifies all discovered wheels, then the released
`inkcre-extension-toolkit` projects one explicit Python inventory into a static sibling Registry.
The exact-head workflow run deploys that tree directly to the dedicated Cloudflare Pages project;
required checks neither upload nor hand off wheels or deployable Registry output. The facade exposes
Expand Down Expand Up @@ -155,6 +155,10 @@ version in `pyproject.toml`; preparing a service Release PR does not change the
Core Host SDK 0.3.1 adds batched JSON identity lookup and transaction advisory locking to the
existing Graph repository. Twitter archive reconciliation requires `>=0.3.1 <0.4.0`.

Host SDK 0.3.3 adds optional PostgreSQL blob row locks to shared graph namespace locks, Block/Relation row locks and repeatable-read
graph scopes. Existing consumers retain their defaults. The CalDAV Distribution requires
`>=0.3.3 <0.4.0` because its conditional writes depend on these capabilities.

GitHub, Learn English, Mail, RSS, and Telegram retain their SDK 0.2 lower bound and support
SDK 0.3 through `>=0.2.0 <0.4.0`. Memos uses the new HTTP address API and requires
`>=0.3.0 <0.4.0`. Compatibility metadata changes require new immutable Extension
Expand Down
1 change: 1 addition & 0 deletions extensions/caldav/.changes/caldav.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
新增 CalDAV 和 CardDAV 第一方扩展,日历、任务和联系人共享信息库;电话、描述及媒体通过通用 Block 和 Relation 复用,支持旧记录原子迁移与 DAV 双向同步。
1 change: 1 addition & 0 deletions extensions/caldav/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
35 changes: 35 additions & 0 deletions extensions/caldav/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""CalDAV and CardDAV access to the shared InKCre info-base."""

from app.business.extension import ExtensionBase
from .config import CalDAVConfig


class Extension(ExtensionBase[CalDAVConfig], ext_id="caldav", config_cls=CalDAVConfig):
@classmethod
def api_dependencies(cls):
return []

@classmethod
def _register_apis(cls, router):
from .connection import register_connection_route
from .protocol import register_protocol

register_connection_route(router)
register_protocol(router)

@classmethod
def peer_inbounds(cls):
from .connection import CONNECTION_INBOUND

return (CONNECTION_INBOUND,)

@classmethod
def _init_resolvers(cls):
from . import resolver # noqa: F401

@classmethod
async def on_start_async(cls, app):
await super().on_start_async(app)
from .graph import migrate_legacy_graph

await migrate_legacy_graph()
Loading
Loading