perceptkit 0.2.8__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {perceptkit-0.2.8 → perceptkit-0.3.0}/CHANGELOG.md +46 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/PKG-INFO +1 -1
- {perceptkit-0.2.8 → perceptkit-0.3.0}/pyproject.toml +1 -1
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/conformance/memory.py +22 -5
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/conformance/suite.py +7 -7
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/records.py +11 -2
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/ports/storage.py +21 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/source_sync.py +79 -7
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/retention.py +62 -9
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_export.py +2 -2
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_queries.py +2 -2
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_recurrence.py +1 -1
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_regressions.py +2 -2
- perceptkit-0.3.0/tests/test_retention.py +132 -0
- perceptkit-0.3.0/tests/test_retention_single_truth.py +80 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_source_mirror.py +4 -4
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_source_sync.py +137 -3
- {perceptkit-0.2.8 → perceptkit-0.3.0}/uv.lock +2 -2
- perceptkit-0.2.8/tests/test_retention.py +0 -97
- {perceptkit-0.2.8 → perceptkit-0.3.0}/.github/workflows/ci.yml +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/.github/workflows/release.yml +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/.gitignore +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/LICENSE +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/NOTES-packaging.md +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/NOTES-quickstart.md +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/README.md +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/docs/PerceptKit-/344/272/247/345/223/201/347/233/256/346/240/207/344/270/216/345/275/223/345/211/215/345/256/236/347/216/260/345/267/256/350/267/235/345/217/215/351/246/210.md" +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/docs/PerceptKit-/346/204/237/347/237/245/345/255/227/346/256/265/344/270/216/345/255/230/345/202/250-/345/267/245/347/250/213/345/257/271/351/275/220/350/241/245/345/205/205/350/256/250/350/256/272.md" +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/docs/reference-storage-mapping.md +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/examples/end_to_end.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/examples/ios_adapter.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/examples/quickstart.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/__init__.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/__init__.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/attribution.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/glance.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/history.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/identity.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/observation.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/streaks.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/trend_models.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/wake.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/catalog.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/conformance/__init__.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/conformance/report.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/conformance/wake.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/__init__.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/_time.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/availability.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/context.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/delivery.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/errors.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/event.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/observation.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/receipt.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/report.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/versioning.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/fields.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/kit.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/manifest/__init__.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/manifest/checks.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/manifest/mapping.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/manifest/minimal.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/manifest/types.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/manifest/units.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/ports/__init__.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/ports/wake.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/__init__.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/aggregate.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/dispatch.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/normalize.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/pipeline.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/recompute.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/recurrence.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/scheduled.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/prompts.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/queries/__init__.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/queries/api.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/rules/__init__.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/rules/engine.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/rules/evaluators.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/rules/types.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/fixtures/README.md +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/fixtures/ios_snapshot_no_data.json +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/fixtures/ios_snapshot_normal.json +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/fixtures/ios_snapshot_unauthorized.json +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_attribution.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_catalog.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_conformance.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_conformance_wake_report.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_contracts.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_delivery_and_records.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_docs_match_code.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_edge_cases.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_end_to_end.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_event_envelope.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_examples.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_identity.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_ios_fixture.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_isolation.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_manifest.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_no_host_leakage.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_observation.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_pipeline.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_projection.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_purity.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_recompute.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_retention_entry.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_revision_recompute.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_rules.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_scheduled.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_streaks.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_trend_models.py +0 -0
- {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_wake.py +0 -0
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-09-03
|
|
4
|
+
|
|
5
|
+
外部审查(2026-09-03)里**不需要产品拍板的那批契约漏洞**。其中三条是审查者
|
|
6
|
+
自己复现的,一条是我们没发现的双重真相。
|
|
7
|
+
|
|
8
|
+
### ⚠️ 破坏性变更
|
|
9
|
+
|
|
10
|
+
- **``CalendarEventMirror`` / ``ReminderItemMirror`` 新增必填字段 ``source``。**
|
|
11
|
+
它是唯一身份的一部分,不是标签 —— 少了它,一次 ``source="ios"`` 的全量同步
|
|
12
|
+
会把概念上属于 Google 的日程一起删掉(快照收尾删的是"这轮没见到的",
|
|
13
|
+
而另一个来源的条目当然没在这轮里)。用户会发现自己另一个日历账户的日程
|
|
14
|
+
凭空消失,且不可逆。
|
|
15
|
+
走 ``sync_source_mirror`` 的调用方不用改:kit 会用这一批声明的 ``source``
|
|
16
|
+
盖上去,和 ``sync_id`` 同一个道理。
|
|
17
|
+
- **``StoragePort`` 新增 ``delete_source_items``**(见下)。
|
|
18
|
+
|
|
19
|
+
### 修复
|
|
20
|
+
|
|
21
|
+
- **增量同步执行来源明确的删除(§8.1)。** 早先把增量定义成"一条都不许删",
|
|
22
|
+
防住了"拿局部列表当全量",但同时堵死了另一条:来源的 change feed 明确
|
|
23
|
+
传来一条删除时,那是**确定的事实**,不是推断。结果是用户在手机上删掉的
|
|
24
|
+
日程,在 agent 眼里永远还在,还会一直出现在"接下来有什么安排"里。
|
|
25
|
+
``SyncBatch.deleted_item_ids`` + ``StoragePort.delete_source_items``;
|
|
26
|
+
和全量收尾的删除**分开计数**,否则分不清某次异常删除是范围判断出错
|
|
27
|
+
还是来源真的删了。
|
|
28
|
+
- **来源进入镜像唯一身份(§8.2)。** 见上面的破坏性变更。
|
|
29
|
+
- **``collection_kind`` 与条目类型交叉校验(§8.3)。**
|
|
30
|
+
``collection_kind="reminders"`` 配一批日历条目原本会被照单全收:日历表
|
|
31
|
+
被写进去了,而**提醒的游标往前推进了** —— 数据和游标从此互相矛盾,
|
|
32
|
+
下一轮增量提醒同步会以为上一轮成功了,那段提醒永远补不回来。
|
|
33
|
+
- **镜像条目的 subject 一律用可信上下文覆盖(§8.4)。** 条目是宿主从来源
|
|
34
|
+
数据翻译出来的,它自带的 ``subject_id`` 最好的情况是冗余、最坏的情况是
|
|
35
|
+
把 A 的日程写进 B 的花园。可信 subject 只有一个来源:``IngestContext``。
|
|
36
|
+
- **``retention_days()`` / ``stores_history()`` 改从 manifest 查(§9)。**
|
|
37
|
+
它们原本读本模块顶上那张旧表,和 manifest **七条全对不上**:
|
|
38
|
+
``focus_state`` 抛 KeyError(manifest 说 365)、``audio_route`` 返回 90
|
|
39
|
+
(manifest 说 7)。接入方和工程 AI 调公开 API 会拿到错的结论,而且没有
|
|
40
|
+
任何地方报错 —— 两个真相各自自洽,只是不一样。
|
|
41
|
+
旧名(``focus`` / ``playback`` / ``location_signal``)作为显式的、
|
|
42
|
+
受测试的别名保留;那张旧表降级成历史记录,不再是任何查询的依据。
|
|
43
|
+
|
|
44
|
+
🔴 ``retention_days()`` 对不进历史表的信号**仍然抛 ``KeyError``,
|
|
45
|
+
不返回 ``None``** —— 这条早先的决策改用 manifest 之后理由更硬了:
|
|
46
|
+
旧词表里 ``None`` 是「永久保存」,静默返回 ``None`` 会让照旧词表理解的
|
|
47
|
+
调用方把「根本不存历史」读成「永久保留」,两个意思正好相反。
|
|
48
|
+
|
|
3
49
|
## 0.2.8 — 2026-09-02
|
|
4
50
|
|
|
5
51
|
- **来源镜像的同步终于有了和 ``ingest()`` 对等的入口(P0-2)。**
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "perceptkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -196,14 +196,16 @@ class InMemoryStorage:
|
|
|
196
196
|
self.sync_state[(state.subject_id, state.source, state.collection_kind)] = state
|
|
197
197
|
|
|
198
198
|
def upsert_calendar_events(self, *, subject_id, events) -> None:
|
|
199
|
+
# 键里带 source —— 少了它,两个来源系统里碰巧同 id 的日程会互相覆盖,
|
|
200
|
+
# 而全量同步还会把另一个来源的条目当成"这轮没见到"删掉。
|
|
199
201
|
for e in events:
|
|
200
|
-
self.calendar[(subject_id, e.
|
|
201
|
-
e.source_event_id)] = e
|
|
202
|
+
self.calendar[(subject_id, e.source, e.source_account_id,
|
|
203
|
+
e.source_calendar_id, e.source_event_id)] = e
|
|
202
204
|
|
|
203
205
|
def upsert_reminders(self, *, subject_id, items) -> None:
|
|
204
206
|
for r in items:
|
|
205
|
-
self.reminders[(subject_id, r.
|
|
206
|
-
r.source_reminder_id)] = r
|
|
207
|
+
self.reminders[(subject_id, r.source, r.source_account_id,
|
|
208
|
+
r.source_list_id, r.source_reminder_id)] = r
|
|
207
209
|
|
|
208
210
|
def list_calendar_events(self, *, subject_id, start=None, end=None,
|
|
209
211
|
limit=50, offset=0):
|
|
@@ -235,6 +237,17 @@ class InMemoryStorage:
|
|
|
235
237
|
i.source_reminder_id))
|
|
236
238
|
return keep[offset:offset + limit]
|
|
237
239
|
|
|
240
|
+
def delete_source_items(self, *, subject_id, source, collection_kind,
|
|
241
|
+
source_item_ids) -> int:
|
|
242
|
+
store = self.calendar if collection_kind == "calendar" else self.reminders
|
|
243
|
+
wanted = set(source_item_ids)
|
|
244
|
+
# key = (subject, source, account, collection, item_id) —— 最后一位是 id。
|
|
245
|
+
doomed = [k for k in store
|
|
246
|
+
if k[0] == subject_id and k[1] == source and k[-1] in wanted]
|
|
247
|
+
for k in doomed:
|
|
248
|
+
del store[k]
|
|
249
|
+
return len(doomed)
|
|
250
|
+
|
|
238
251
|
def apply_source_snapshot(self, *, subject_id, source, collection_kind, sync_id,
|
|
239
252
|
coverage_start, coverage_end, snapshot_kind) -> int:
|
|
240
253
|
# 增量同步没有资格删任何东西 —— 它只知道"变了什么",不知道"还剩什么"。
|
|
@@ -243,7 +256,11 @@ class InMemoryStorage:
|
|
|
243
256
|
store = self.calendar if collection_kind == "calendar" else self.reminders
|
|
244
257
|
doomed = []
|
|
245
258
|
for key, item in store.items():
|
|
246
|
-
|
|
259
|
+
# 🔴 必须同时限定 subject **和 source**。只按 subject 删的话,
|
|
260
|
+
# 一次 source="ios" 的全量同步会把 Google 日历的条目一起删掉 ——
|
|
261
|
+
# 它们当然没出现在这一轮 ios 的批次里。
|
|
262
|
+
if (key[0] != subject_id or item.source != source
|
|
263
|
+
or item.last_seen_sync_id == sync_id):
|
|
247
264
|
continue
|
|
248
265
|
# 🔴 只删【能证明落在覆盖范围内】的。拿局部窗口去删窗口外的数据,
|
|
249
266
|
# 会让用户发现自己去年的日程凭空消失,而且不可逆。
|
|
@@ -228,12 +228,12 @@ def _g8_partial_sync_does_not_delete_outside_its_window(new: StorageFactory) ->
|
|
|
228
228
|
problems: list[str] = []
|
|
229
229
|
s = new()
|
|
230
230
|
inside = CalendarEventMirror(
|
|
231
|
-
subject_id="u1", source_account_id="a", source_calendar_id="c",
|
|
231
|
+
subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
|
|
232
232
|
source_event_id="e_in", event_fields={"start_at": T0},
|
|
233
233
|
last_seen_sync_id="old",
|
|
234
234
|
)
|
|
235
235
|
outside = CalendarEventMirror(
|
|
236
|
-
subject_id="u1", source_account_id="a", source_calendar_id="c",
|
|
236
|
+
subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
|
|
237
237
|
source_event_id="e_out", event_fields={"start_at": T0 - timedelta(days=400)},
|
|
238
238
|
last_seen_sync_id="old",
|
|
239
239
|
)
|
|
@@ -348,7 +348,7 @@ def _g11_both_source_mirrors_round_trip(new: StorageFactory) -> list[str]:
|
|
|
348
348
|
problems: list[str] = []
|
|
349
349
|
s = new()
|
|
350
350
|
s.upsert_calendar_events(subject_id="u1", events=[CalendarEventMirror(
|
|
351
|
-
subject_id="u1", source_account_id="a", source_calendar_id="c",
|
|
351
|
+
subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
|
|
352
352
|
source_event_id="e1", event_fields={"title": "站会", "start_at": T0},
|
|
353
353
|
)])
|
|
354
354
|
got = list(s.list_calendar_events(subject_id="u1", limit=10))
|
|
@@ -357,7 +357,7 @@ def _g11_both_source_mirrors_round_trip(new: StorageFactory) -> list[str]:
|
|
|
357
357
|
|
|
358
358
|
s2 = new()
|
|
359
359
|
s2.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
|
|
360
|
-
subject_id="u1", source_account_id="a", source_list_id="l",
|
|
360
|
+
subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
|
|
361
361
|
source_reminder_id="r1", reminder_fields={"title": "买牛奶",
|
|
362
362
|
"is_completed": False},
|
|
363
363
|
)])
|
|
@@ -368,7 +368,7 @@ def _g11_both_source_mirrors_round_trip(new: StorageFactory) -> list[str]:
|
|
|
368
368
|
)
|
|
369
369
|
# 已完成的默认不出现,除非明说要。
|
|
370
370
|
s2.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
|
|
371
|
-
subject_id="u1", source_account_id="a", source_list_id="l",
|
|
371
|
+
subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
|
|
372
372
|
source_reminder_id="r2", reminder_fields={"title": "交房租",
|
|
373
373
|
"is_completed": True},
|
|
374
374
|
)])
|
|
@@ -447,7 +447,7 @@ def _g12_terminal_events_and_offsets_are_queryable(new: StorageFactory) -> list[
|
|
|
447
447
|
|
|
448
448
|
s2 = new()
|
|
449
449
|
s2.upsert_calendar_events(subject_id="u1", events=[CalendarEventMirror(
|
|
450
|
-
subject_id="u1", source_account_id="a", source_calendar_id="c",
|
|
450
|
+
subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
|
|
451
451
|
source_event_id=f"e{i}",
|
|
452
452
|
event_fields={"title": f"e{i}", "start_at": base + timedelta(minutes=i)},
|
|
453
453
|
) for i in range(4)])
|
|
@@ -460,7 +460,7 @@ def _g12_terminal_events_and_offsets_are_queryable(new: StorageFactory) -> list[
|
|
|
460
460
|
|
|
461
461
|
s3 = new()
|
|
462
462
|
s3.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
|
|
463
|
-
subject_id="u1", source_account_id="a", source_list_id="l",
|
|
463
|
+
subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
|
|
464
464
|
source_reminder_id=f"r{i}",
|
|
465
465
|
reminder_fields={"title": f"r{i}", "is_completed": False,
|
|
466
466
|
"due_at": base + timedelta(minutes=i)},
|
|
@@ -218,11 +218,18 @@ class CalendarEventMirror:
|
|
|
218
218
|
**镜像不是快照历史。** 存的是"来源现在有哪些条目",条目自己带着
|
|
219
219
|
过去或未来的时间;不存"我们每次同步时看到了什么"。来源删除 → 本地删除。
|
|
220
220
|
|
|
221
|
-
|
|
222
|
-
|
|
221
|
+
唯一身份必须**包含来源系统、来源账户和日历**:不同账户碰巧用同一个
|
|
222
|
+
event id 是完全可能的,不同来源系统更是必然。
|
|
223
223
|
"""
|
|
224
224
|
|
|
225
225
|
subject_id: str
|
|
226
|
+
#: 哪个来源系统(``ios`` / ``google`` / ``exchange`` …)。
|
|
227
|
+
#:
|
|
228
|
+
#: 🔴 **它是唯一身份的一部分,不是标签。** 少了它,一次
|
|
229
|
+
#: ``source="ios"`` 的全量同步会把概念上属于 Google 的日程一起删掉 ——
|
|
230
|
+
#: 快照收尾删的是「这轮没见到的」,而另一个来源的条目当然没在这轮里。
|
|
231
|
+
#: 用户发现自己另一个日历账户的日程凭空消失了,且不可逆。
|
|
232
|
+
source: str
|
|
226
233
|
source_account_id: str
|
|
227
234
|
source_calendar_id: str
|
|
228
235
|
source_event_id: str
|
|
@@ -242,6 +249,8 @@ class ReminderItemMirror:
|
|
|
242
249
|
"""外部提醒里现在还存在的一条待办。"""
|
|
243
250
|
|
|
244
251
|
subject_id: str
|
|
252
|
+
#: 哪个来源系统。理由同 :class:`CalendarEventMirror.source`。
|
|
253
|
+
source: str
|
|
245
254
|
source_account_id: str
|
|
246
255
|
source_list_id: str
|
|
247
256
|
source_reminder_id: str
|
|
@@ -215,6 +215,27 @@ class StoragePort(Protocol):
|
|
|
215
215
|
"""镜像里现在还存在的提醒事项。``offset`` 的要求同上。"""
|
|
216
216
|
...
|
|
217
217
|
|
|
218
|
+
def delete_source_items(
|
|
219
|
+
self, *, subject_id: str, source: str, collection_kind: str,
|
|
220
|
+
source_item_ids: Sequence[str],
|
|
221
|
+
) -> int:
|
|
222
|
+
"""删掉来源**明确说删了**的那几条,返回删了几条。
|
|
223
|
+
|
|
224
|
+
和 :meth:`apply_source_snapshot` 是两件事,别合并:
|
|
225
|
+
|
|
226
|
+
全量收尾 "覆盖范围内、这轮没见到的" —— 推断出来的,所以只有
|
|
227
|
+
全量有资格,而且必须限定在声明的范围内
|
|
228
|
+
这个方法 "来源说这条删了" —— 确定的事实,增量也必须执行
|
|
229
|
+
|
|
230
|
+
没有这个方法的话,增量同步只能选:要么一条都不删(用户在手机上
|
|
231
|
+
删掉的日程,在 agent 眼里永远还在,还会一直出现在"接下来有什么
|
|
232
|
+
安排"里),要么拿局部列表当全量删(更糟,且不可逆)。
|
|
233
|
+
|
|
234
|
+
🔴 ``source`` 是删除范围的一部分。少了它,一次 ``ios`` 的删除会
|
|
235
|
+
命中另一个来源系统里碰巧同 id 的条目。
|
|
236
|
+
"""
|
|
237
|
+
...
|
|
238
|
+
|
|
218
239
|
def apply_source_snapshot(
|
|
219
240
|
self, *, subject_id: str, source: str, collection_kind: str,
|
|
220
241
|
sync_id: str, coverage_start: datetime, coverage_end: datetime,
|
|
@@ -46,6 +46,9 @@ INCREMENTAL = "incremental"
|
|
|
46
46
|
CALENDAR = "calendar"
|
|
47
47
|
REMINDERS = "reminders"
|
|
48
48
|
|
|
49
|
+
#: 集合种类 -> 它唯一接受的条目类型。
|
|
50
|
+
_ITEM_TYPE = {CALENDAR: CalendarEventMirror, REMINDERS: ReminderItemMirror}
|
|
51
|
+
|
|
49
52
|
|
|
50
53
|
class SyncContractError(ValueError):
|
|
51
54
|
"""这一批的声明本身自相矛盾,处理它会造成不可逆的损失。
|
|
@@ -73,6 +76,13 @@ class SyncBatch:
|
|
|
73
76
|
cursor: str | None = None
|
|
74
77
|
attempted_at: datetime | None = None
|
|
75
78
|
completed_at: datetime | None = None
|
|
79
|
+
#: 来源**明确说**被删掉的条目身份。见 ``sync_source_mirror`` 的文档。
|
|
80
|
+
#:
|
|
81
|
+
#: 和「这一批里没出现」是两回事:没出现推断不出删除(增量只知道变了什么,
|
|
82
|
+
#: 不知道还剩什么),但来源的 change feed 明确传来一条删除时,
|
|
83
|
+
#: 那是确定的事实,必须执行 —— 否则用户在手机上删掉的日程,
|
|
84
|
+
#: 在 agent 眼里永远还在。
|
|
85
|
+
deleted_item_ids: Sequence[str] = field(default_factory=tuple)
|
|
76
86
|
#: 非空 = 这一批**没有成功拿到**。见 ``SyncOutcome`` 的文档。
|
|
77
87
|
error_code: str | None = None
|
|
78
88
|
|
|
@@ -82,7 +92,12 @@ class SyncOutcome:
|
|
|
82
92
|
"""这一轮做了什么。``failed`` 时 ``upserted`` / ``deleted`` 一定是 0。"""
|
|
83
93
|
|
|
84
94
|
upserted: int = 0
|
|
95
|
+
#: 全量收尾按范围删掉的条数。
|
|
85
96
|
deleted: int = 0
|
|
97
|
+
#: 按来源明确的 tombstone 删掉的条数。和上面一个分开数 —— 一个是
|
|
98
|
+
#: "这轮没见到所以删",一个是"来源说删了",混在一起就分不清
|
|
99
|
+
#: 某次异常删除是范围判断出错还是来源真的删了。
|
|
100
|
+
tombstoned: int = 0
|
|
86
101
|
failed: bool = False
|
|
87
102
|
#: 记下来的错误码,原样来自这一批。
|
|
88
103
|
error_code: str | None = None
|
|
@@ -162,6 +177,8 @@ def sync_source_mirror(
|
|
|
162
177
|
|
|
163
178
|
with storage.transaction():
|
|
164
179
|
upserted = _upsert(storage, batch, context)
|
|
180
|
+
# 来源明确的删除,全量和增量都执行 —— 它不是推断出来的。
|
|
181
|
+
tombstoned = _apply_tombstones(storage, batch, context)
|
|
165
182
|
deleted = 0
|
|
166
183
|
if kind == FULL:
|
|
167
184
|
# 只有全量才有资格删,而且只在它自己声明的范围内。
|
|
@@ -189,10 +206,34 @@ def sync_source_mirror(
|
|
|
189
206
|
last_error_code=None,
|
|
190
207
|
))
|
|
191
208
|
return SyncOutcome(upserted=upserted, deleted=deleted,
|
|
209
|
+
tombstoned=tombstoned,
|
|
192
210
|
cursor=batch.cursor if batch.cursor is not None
|
|
193
211
|
else prior_cursor)
|
|
194
212
|
|
|
195
213
|
|
|
214
|
+
def _apply_tombstones(storage: StoragePort, batch: SyncBatch,
|
|
215
|
+
context: IngestContext) -> int:
|
|
216
|
+
"""执行来源**明确传来**的删除。
|
|
217
|
+
|
|
218
|
+
这和全量收尾的删除是两件事,别合并:
|
|
219
|
+
|
|
220
|
+
全量收尾 "覆盖范围内、这轮没见到的" —— 推断出来的,所以只有全量
|
|
221
|
+
有资格,而且必须限定在声明的范围内
|
|
222
|
+
tombstone "来源说这条删了" —— 确定的事实,增量也必须执行
|
|
223
|
+
|
|
224
|
+
早先把增量定义成「一条都不许删」,防住了「拿局部列表当全量」,
|
|
225
|
+
但同时也堵死了这条:用户在手机上删掉的日程,在 agent 眼里永远还在,
|
|
226
|
+
而且它会一直出现在"接下来有什么安排"里。
|
|
227
|
+
"""
|
|
228
|
+
ids = [str(i) for i in (batch.deleted_item_ids or ()) if str(i).strip()]
|
|
229
|
+
if not ids:
|
|
230
|
+
return 0
|
|
231
|
+
return int(storage.delete_source_items(
|
|
232
|
+
subject_id=context.subject_id, source=batch.source,
|
|
233
|
+
collection_kind=batch.collection_kind, source_item_ids=ids,
|
|
234
|
+
) or 0)
|
|
235
|
+
|
|
236
|
+
|
|
196
237
|
def _upsert(storage: StoragePort, batch: SyncBatch,
|
|
197
238
|
context: IngestContext) -> int:
|
|
198
239
|
items = list(batch.items)
|
|
@@ -204,20 +245,51 @@ def _upsert(storage: StoragePort, batch: SyncBatch,
|
|
|
204
245
|
# 不打的话,刚写进去的条目在同一个事务里被自己的快照收尾删掉 ——
|
|
205
246
|
# 一次"成功"的全量同步,结果是镜像空了。
|
|
206
247
|
# 这个由 kit 打,不指望宿主记得:忘了不报错,只是数据没了。
|
|
207
|
-
|
|
248
|
+
# 这一批声明的 source 和 sync_id 由 kit 盖上去,不要求宿主在每个条目上
|
|
249
|
+
# 再写一遍。两者忘了都不报错,但后果不一样:
|
|
250
|
+
# sync_id 忘了 → 刚写进去的被自己的快照收尾删掉
|
|
251
|
+
# source 忘了 → 这批条目落在别的来源名下,下次那个来源的全量同步删掉它们
|
|
252
|
+
# 🔴 subject 一律用**可信上下文**的,不用条目自己带的那个。
|
|
253
|
+
#
|
|
254
|
+
# 条目是宿主从来源数据翻译出来的,它带的 subject_id 最好的情况是冗余、
|
|
255
|
+
# 最坏的情况是把 A 的日程写进 B 的花园。可信 subject 只有一个来源:
|
|
256
|
+
# IngestContext —— 那是宿主鉴权之后填的,不经过来源数据也不经过模型。
|
|
257
|
+
# 校验一致再拒绝也行,但覆盖更彻底:没有"该信哪个"这个问题存在。
|
|
258
|
+
items = [replace(i, subject_id=context.subject_id, source=batch.source,
|
|
259
|
+
last_seen_sync_id=batch.sync_id)
|
|
260
|
+
for i in items]
|
|
208
261
|
kinds = {type(i) for i in items}
|
|
209
|
-
if kinds
|
|
210
|
-
storage.upsert_calendar_events(subject_id=context.subject_id,
|
|
211
|
-
events=items)
|
|
212
|
-
elif kinds == {ReminderItemMirror}:
|
|
213
|
-
storage.upsert_reminders(subject_id=context.subject_id, items=items)
|
|
214
|
-
else:
|
|
262
|
+
if len(kinds) > 1:
|
|
215
263
|
# 混着来说明宿主那边的翻译出了问题。挑一部分写进去、剩下的丢掉,
|
|
216
264
|
# 会得到一份"成功了"的半份镜像。
|
|
217
265
|
raise SyncContractError(
|
|
218
266
|
f"一批里混了 {sorted(k.__name__ for k in kinds)}:"
|
|
219
267
|
f"一次同步只处理一种集合,混着来会写出一份看起来成功的半份镜像"
|
|
220
268
|
)
|
|
269
|
+
# 🔴 声明的集合种类必须和条目类型对上。
|
|
270
|
+
#
|
|
271
|
+
# 不校验的话,「collection_kind=reminders + 一批日历条目」会被照单全收:
|
|
272
|
+
# 日历表被写进去了,而**提醒的同步游标往前推进了** —— 数据和游标从此
|
|
273
|
+
# 互相矛盾,下一轮增量提醒同步会以为上一轮成功了,那段提醒永远补不回来。
|
|
274
|
+
expected = _ITEM_TYPE.get(batch.collection_kind)
|
|
275
|
+
if expected is None:
|
|
276
|
+
raise SyncContractError(
|
|
277
|
+
f"不认识的 collection_kind={batch.collection_kind!r}:"
|
|
278
|
+
f"只有 {CALENDAR!r} 和 {REMINDERS!r}。放过去的话,"
|
|
279
|
+
f"这批数据会落在一个没人读的地方,而同步状态说成功了"
|
|
280
|
+
)
|
|
281
|
+
actual = kinds.pop()
|
|
282
|
+
if actual is not expected:
|
|
283
|
+
raise SyncContractError(
|
|
284
|
+
f"collection_kind={batch.collection_kind!r} 声明的是 "
|
|
285
|
+
f"{expected.__name__},实际给的是 {actual.__name__}:"
|
|
286
|
+
f"照做会把数据写进一张表、把另一张表的游标往前推"
|
|
287
|
+
)
|
|
288
|
+
if expected is CalendarEventMirror:
|
|
289
|
+
storage.upsert_calendar_events(subject_id=context.subject_id,
|
|
290
|
+
events=items)
|
|
291
|
+
else:
|
|
292
|
+
storage.upsert_reminders(subject_id=context.subject_id, items=items)
|
|
221
293
|
return len(items)
|
|
222
294
|
|
|
223
295
|
|
|
@@ -28,6 +28,12 @@ KEEP_FOREVER = None
|
|
|
28
28
|
|
|
29
29
|
_DAY = 86400.0
|
|
30
30
|
|
|
31
|
+
# ⚠️ **这张表不再是任何查询的依据。** 它是早期设计的记录,键还是旧目录名。
|
|
32
|
+
# 唯一的真相是 manifest(``manifest.minimal.MINIMAL_SIGNALS``),
|
|
33
|
+
# ``retention_days()`` / ``stores_history()`` 现在都从那里查。
|
|
34
|
+
# 保留它是因为下面那段 weather 的注释记着一次真实的四表打架,值得留着;
|
|
35
|
+
# 但**别再往里加条目,也别照它做判断**。
|
|
36
|
+
#
|
|
31
37
|
# signal -> 保留天数;None = 永久;不在表里 = 不进历史表
|
|
32
38
|
RETENTION_DAYS: dict[str, int | None] = {
|
|
33
39
|
# 永久:趋势本身就是价值
|
|
@@ -178,17 +184,64 @@ def plan_retention(
|
|
|
178
184
|
return plan
|
|
179
185
|
|
|
180
186
|
|
|
187
|
+
#: 旧目录名 -> manifest 里的信号名。
|
|
188
|
+
#:
|
|
189
|
+
#: 这是一张**显式的兼容表**,不是第二套目录。它存在的唯一理由是老调用方还在
|
|
190
|
+
#: 用旧名;查出来的答案一律来自 manifest。
|
|
191
|
+
#:
|
|
192
|
+
#: 🔴 审查(2026-09-03 §9)复现的:这两个公开函数原本读的是本模块自己那张
|
|
193
|
+
#: ``RETENTION_DAYS``,和 manifest 七条全对不上 ——
|
|
194
|
+
#: ``retention_days("focus_state")`` 抛 KeyError(manifest 说 365)、
|
|
195
|
+
#: ``retention_days("audio_route")`` 返回 90(manifest 说 7)。
|
|
196
|
+
#: 接入方和工程 AI 调公开 API 会拿到错的结论,而且没有任何地方报错。
|
|
197
|
+
#: **同一件事有两个真相时,公开 API 必须指向那个会被执行的那个。**
|
|
198
|
+
_LEGACY_NAMES: dict[str, str] = {
|
|
199
|
+
"focus": "focus_state",
|
|
200
|
+
"playback": "music_playback",
|
|
201
|
+
"location_signal": "location_city",
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _resolve(signal: str) -> "SignalDefinition | None":
|
|
206
|
+
from .manifest.minimal import MINIMAL_SIGNALS
|
|
207
|
+
return MINIMAL_SIGNALS.get(_LEGACY_NAMES.get(signal, signal))
|
|
208
|
+
|
|
209
|
+
|
|
181
210
|
def stores_history(signal: str) -> bool:
|
|
182
|
-
"""
|
|
183
|
-
|
|
211
|
+
"""这个信号进不进历史表。**答案来自 manifest。**"""
|
|
212
|
+
sig = _resolve(signal)
|
|
213
|
+
return bool(sig and sig.stores_history)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def retention_days(signal: str) -> int:
|
|
217
|
+
"""明细保留多少天;``PERMANENT``(-1)= 永久。
|
|
184
218
|
|
|
219
|
+
**答案来自 manifest**,不是本模块顶上那张历史表 —— 那张表现在只作为
|
|
220
|
+
早期设计的记录留着,不再是任何查询的依据。
|
|
185
221
|
|
|
186
|
-
|
|
187
|
-
|
|
222
|
+
🔴 **不进历史表的信号抛 ``KeyError``,不返回 ``None``。**
|
|
223
|
+
这是本模块早就定下的决策,改用 manifest 之后仍然成立,而且理由更硬了:
|
|
224
|
+
旧表里 ``None`` 是「永久保存」(``KEEP_FOREVER``)。静默返回 ``None``
|
|
225
|
+
的话,一个照旧词表理解的调用方会把「这个信号根本不存历史」读成
|
|
226
|
+
「永久保留」—— 两个意思正好相反,而且不会有任何地方报错。
|
|
188
227
|
|
|
189
|
-
|
|
190
|
-
误当成「永久」,那是最坏的一种默认值。
|
|
228
|
+
问"存不存历史"用 :func:`stores_history`,那才是它该回答的问题。
|
|
191
229
|
"""
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
230
|
+
sig = _resolve(signal)
|
|
231
|
+
if sig is None or not sig.stores_history:
|
|
232
|
+
raise KeyError(
|
|
233
|
+
f"{signal!r} 不进历史表,没有明细保留期。"
|
|
234
|
+
f"想问存不存历史用 stores_history();"
|
|
235
|
+
f"这里不返回 None —— 旧词表里 None 是「永久保存」,"
|
|
236
|
+
f"两个意思正好相反"
|
|
237
|
+
)
|
|
238
|
+
return sig.history_retention_days
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
__all__ = [
|
|
242
|
+
"KEEP_FOREVER", "RETENTION_DAYS", "MEASURED_AT_TTL_SEC", "LIFECYCLE_NOTE",
|
|
243
|
+
"RetentionAction", "RetentionPlan", "SkippedSignal", "plan_retention",
|
|
244
|
+
"SKIP_NO_HISTORY", "SKIP_DETAILS_PERMANENT", "SKIP_DETAILS_UNDECLARED",
|
|
245
|
+
"SKIP_AGGREGATES_PERMANENT", "SKIP_AGGREGATES_UNDECLARED",
|
|
246
|
+
"stores_history", "retention_days",
|
|
247
|
+
]
|
|
@@ -36,11 +36,11 @@ def build() -> tuple[PerceptionKit, InMemoryStorage]:
|
|
|
36
36
|
],
|
|
37
37
|
}, context=IngestContext("u1", when("09:00")))
|
|
38
38
|
s.upsert_calendar_events(subject_id="u1", events=[CalendarEventMirror(
|
|
39
|
-
subject_id="u1", source_account_id="a", source_calendar_id="c",
|
|
39
|
+
subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
|
|
40
40
|
source_event_id="e1",
|
|
41
41
|
event_fields={"title": "牙医", "start_at": when("15:00")})])
|
|
42
42
|
s.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
|
|
43
|
-
subject_id="u1", source_account_id="a", source_list_id="l",
|
|
43
|
+
subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
|
|
44
44
|
source_reminder_id="r1",
|
|
45
45
|
reminder_fields={"title": "交房租", "is_completed": True})])
|
|
46
46
|
return kit, s
|
|
@@ -480,7 +480,7 @@ def test_the_calendar_pages_like_everything_else():
|
|
|
480
480
|
s = InMemoryStorage()
|
|
481
481
|
s.upsert_calendar_events(subject_id="u1", events=[
|
|
482
482
|
CalendarEventMirror(
|
|
483
|
-
subject_id="u1", source_account_id="a", source_calendar_id="c",
|
|
483
|
+
subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
|
|
484
484
|
source_event_id=f"e{i}",
|
|
485
485
|
event_fields={"title": f"会 {i}", "start_at": t(f"2026-08-{i+1:02d}")})
|
|
486
486
|
for i in range(5)])
|
|
@@ -497,7 +497,7 @@ def test_the_reminder_list_pages_too():
|
|
|
497
497
|
s = InMemoryStorage()
|
|
498
498
|
s.upsert_reminders(subject_id="u1", items=[
|
|
499
499
|
ReminderItemMirror(
|
|
500
|
-
subject_id="u1", source_account_id="a", source_list_id="l",
|
|
500
|
+
subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
|
|
501
501
|
source_reminder_id=f"r{i}",
|
|
502
502
|
reminder_fields={"title": f"事 {i}", "is_completed": False})
|
|
503
503
|
for i in range(5)])
|
|
@@ -149,7 +149,7 @@ def series(rule: dict | None = None) -> CalendarEventMirror:
|
|
|
149
149
|
if rule is not None:
|
|
150
150
|
fields["recurrence"] = rule
|
|
151
151
|
return CalendarEventMirror(
|
|
152
|
-
subject_id="u1", source_account_id="a", source_calendar_id="c",
|
|
152
|
+
subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
|
|
153
153
|
source_event_id="weekly-standup", event_fields=fields,
|
|
154
154
|
recurrence_identity="series-1",
|
|
155
155
|
)
|
|
@@ -929,7 +929,7 @@ def _reminders(storage, subject, n):
|
|
|
929
929
|
for i in range(n):
|
|
930
930
|
rid = f"r{i:04d}"
|
|
931
931
|
storage.reminders[(subject, rid)] = ReminderItemMirror(
|
|
932
|
-
subject_id=subject, source_account_id="a", source_list_id="l",
|
|
932
|
+
subject_id=subject, source="ios", source_account_id="a", source_list_id="l",
|
|
933
933
|
source_reminder_id=rid,
|
|
934
934
|
reminder_fields={"title": rid, "due_at": base + timedelta(minutes=i)},
|
|
935
935
|
)
|
|
@@ -963,7 +963,7 @@ def test_calendar_pagination_walks_the_whole_mirror():
|
|
|
963
963
|
for i in range(600):
|
|
964
964
|
eid = f"c{i:04d}"
|
|
965
965
|
s.calendar[("u", eid)] = CalendarEventMirror(
|
|
966
|
-
subject_id="u", source_account_id="a", source_calendar_id="c",
|
|
966
|
+
subject_id="u", source="ios", source_account_id="a", source_calendar_id="c",
|
|
967
967
|
source_event_id=eid,
|
|
968
968
|
event_fields={"title": eid, "start_at": base + timedelta(minutes=i)},
|
|
969
969
|
)
|