Skip to main content

max / goingson

Declare GoingsOn SyncSchema manifest (Groups p4 M1) First slice of the GO -> SyncStore migration: declare what GoingsOn syncs in the engine's manifest form, without cutting the live sync path over yet (that is M2). goingson_schema() covers all 21 synced tables in FK order, with email_accounts credential exclusion (preserve_local + insert_defaults) and references_unsynced on tasks/attachments (source_email_id -> the per-device emails table). A behaviour-diff test asserts the manifest's emitted columns equal apply.rs SYNCED_COLUMNS for every table and match UPSERT_ORDER, so no table's sync policy can silently change at cutover. This caught that the engine's reverse-engineered example manifest was missing task_status_tokens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 21:20 UTC
Commit: a33aa733a59525337c2930ffb17bdb0b1d2f5106
Parent: 05bfe0b
3 files changed, +433 insertions, -0 deletions
M Cargo.lock +58
@@ -1171,6 +1171,32 @@ dependencies = [
1171 1171 ]
1172 1172
1173 1173 [[package]]
1174 + name = "curve25519-dalek"
1175 + version = "4.1.3"
1176 + source = "registry+https://github.com/rust-lang/crates.io-index"
1177 + checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
1178 + dependencies = [
1179 + "cfg-if",
1180 + "cpufeatures 0.2.17",
1181 + "curve25519-dalek-derive",
1182 + "fiat-crypto",
1183 + "rustc_version",
1184 + "subtle",
1185 + "zeroize",
1186 + ]
1187 +
1188 + [[package]]
1189 + name = "curve25519-dalek-derive"
1190 + version = "0.1.1"
1191 + source = "registry+https://github.com/rust-lang/crates.io-index"
1192 + checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
1193 + dependencies = [
1194 + "proc-macro2",
1195 + "quote",
1196 + "syn 2.0.117",
1197 + ]
1198 +
1199 + [[package]]
1174 1200 name = "darling"
1175 1201 version = "0.23.0"
1176 1202 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1641,6 +1667,12 @@ dependencies = [
1641 1667 ]
1642 1668
1643 1669 [[package]]
1670 + name = "fiat-crypto"
1671 + version = "0.2.9"
1672 + source = "registry+https://github.com/rust-lang/crates.io-index"
1673 + checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
1674 +
1675 + [[package]]
1644 1676 name = "field-offset"
1645 1677 version = "0.3.6"
1646 1678 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6167,6 +6199,7 @@ dependencies = [
6167 6199 "unicode-normalization",
6168 6200 "urlencoding",
6169 6201 "uuid",
6202 + "x25519-dalek",
6170 6203 "zeroize",
6171 6204 ]
6172 6205
@@ -8479,6 +8512,17 @@ dependencies = [
8479 8512 ]
8480 8513
8481 8514 [[package]]
8515 + name = "x25519-dalek"
8516 + version = "2.0.1"
8517 + source = "registry+https://github.com/rust-lang/crates.io-index"
8518 + checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
8519 + dependencies = [
8520 + "curve25519-dalek",
8521 + "rand_core 0.6.4",
8522 + "zeroize",
8523 + ]
8524 +
8525 + [[package]]
8482 8526 name = "xattr"
8483 8527 version = "1.6.1"
8484 8528 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8629,6 +8673,20 @@ name = "zeroize"
8629 8673 version = "1.8.2"
8630 8674 source = "registry+https://github.com/rust-lang/crates.io-index"
8631 8675 checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
8676 + dependencies = [
8677 + "zeroize_derive",
8678 + ]
8679 +
8680 + [[package]]
8681 + name = "zeroize_derive"
8682 + version = "1.5.0"
8683 + source = "registry+https://github.com/rust-lang/crates.io-index"
8684 + checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
8685 + dependencies = [
8686 + "proc-macro2",
8687 + "quote",
8688 + "syn 2.0.117",
8689 + ]
8632 8690
8633 8691 [[package]]
8634 8692 name = "zerotrie"
@@ -0,0 +1,374 @@
1 + //! The declarative [`SyncSchema`] manifest for GoingsOn's synced tables.
2 + //!
3 + //! This is the M1 slice of the GO -> `SyncStore` migration (SyncKit Groups p4):
4 + //! it *declares* what GO syncs in the engine's manifest form without yet cutting
5 + //! the live sync path over to `SyncStore`. The hand-rolled `push.rs`/`pull.rs`/
6 + //! `apply.rs`/`hlc.rs` still drive sync until M2.
7 + //!
8 + //! The manifest is the single source of truth the engine will use to generate the
9 + //! changelog triggers, the initial snapshot, and the apply-side binding. It must
10 + //! reproduce GO's current behaviour exactly, so the column lists here are the same
11 + //! `SYNCED_COLUMNS` whitelist `apply.rs` uses today, in the same FK-safe
12 + //! [`UPSERT_ORDER`](super::UPSERT_ORDER) (parents first; the engine derives the
13 + //! delete order by reversing it). The `manifest_matches_synced_columns` test below
14 + //! is the behaviour-diff that guards against any table's sync policy silently
15 + //! changing.
16 + //!
17 + //! Three tables need more than the common `t(name, cols)` default:
18 + //! - `email_accounts` syncs config only. Passwords and OAuth tokens never leave
19 + //! the device: they are excluded from the synced columns (so they never enter
20 + //! the changelog), `preserve_local` keeps them untouched on a conflict-update,
21 + //! and `insert_defaults` satisfies the `password` NOT NULL on a fresh remote
22 + //! insert with `''`. This replaces GO's bespoke `apply_email_account_upsert`.
23 + //! - `tasks` and `attachments` both carry `source_email_id`, a foreign key into
24 + //! the `emails` table, which is NOT synced (emails stay per-device). They are
25 + //! marked `references_unsynced` so FK enforcement is relaxed during a remote
26 + //! apply — the engine's equivalent of GO's current blanket `foreign_keys=OFF`
27 + //! over the apply transaction.
28 + //!
29 + //! Groups (M3) will add `.group_scoped("group_id")` to the shareable tables; the
30 + //! personal-only default here means a config/credential table can never leak into
31 + //! a group scope.
32 +
33 + // M1 declares the manifest and proves it faithful (the test below); M2 wires
34 + // `goingson_schema` into the live `SyncStore` path. Until then it is exercised
35 + // only by its own behaviour-diff test, so suppress the non-test dead-code lint.
36 + #![allow(dead_code)]
37 +
38 + use synckit_client::{ConflictStrategy, SyncSchema, SyncTable};
39 +
40 + use super::EMAIL_ACCOUNT_SYNC_COLS;
41 +
42 + /// The common case: single `id` primary key, cleartext wire row-id, `Full`
43 + /// upsert, `Hard` delete, HLC conflict resolution. Most of GO's tables are one
44 + /// line each.
45 + fn t(name: &'static str, cols: &'static [&'static str]) -> SyncTable {
46 + SyncTable::full(name, cols)
47 + }
48 +
49 + /// GoingsOn's sync manifest: every table GO replicates, in FK (parents-first)
50 + /// order matching [`super::UPSERT_ORDER`].
51 + pub(crate) fn goingson_schema() -> SyncSchema {
52 + SyncSchema::new(vec![
53 + t(
54 + "projects",
55 + &[
56 + "id",
57 + "name",
58 + "description",
59 + "project_type",
60 + "status",
61 + "created_at",
62 + "user_id",
63 + ],
64 + ),
65 + t(
66 + "contacts",
67 + &[
68 + "id",
69 + "user_id",
70 + "display_name",
71 + "nickname",
72 + "company",
73 + "title",
74 + "notes",
75 + "tags",
76 + "birthday",
77 + "timezone",
78 + "external_source",
79 + "external_id",
80 + "created_at",
81 + "updated_at",
82 + ],
83 + ),
84 + // Config only, never credentials. See the module docs.
85 + SyncTable::full("email_accounts", EMAIL_ACCOUNT_SYNC_COLS)
86 + .preserve_local(&[
87 + "password",
88 + "oauth2_access_token",
89 + "oauth2_refresh_token",
90 + "oauth2_token_expires_at",
91 + ])
92 + .insert_defaults(&[("password", "''")]),
93 + t(
94 + "sync_accounts",
95 + &[
96 + "id",
97 + "user_id",
98 + "provider",
99 + "account_name",
100 + "email",
101 + "sync_calendars",
102 + "sync_contacts",
103 + "calendar_ids",
104 + "sync_interval_minutes",
105 + "enabled",
106 + "created_at",
107 + ],
108 + ),
109 + t(
110 + "milestones",
111 + &[
112 + "id",
113 + "user_id",
114 + "project_id",
115 + "name",
116 + "description",
117 + "position",
118 + "target_date",
119 + "status",
120 + "created_at",
121 + ],
122 + ),
123 + // Carries source_email_id -> the unsynced emails table.
124 + SyncTable::full(
125 + "tasks",
126 + &[
127 + "id",
128 + "project_id",
129 + "description",
130 + "status",
131 + "priority",
132 + "due",
133 + "tags",
134 + "urgency",
135 + "recurrence",
136 + "recurrence_rule",
137 + "created_at",
138 + "user_id",
139 + "recurrence_parent_id",
140 + "source_email_id",
141 + "snoozed_until",
142 + "waiting_for_response",
143 + "waiting_since",
144 + "expected_response_date",
145 + "scheduled_start",
146 + "scheduled_duration",
147 + "is_focus",
148 + "focus_set_at",
149 + "contact_id",
150 + "milestone_id",
151 + "completed_at",
152 + "estimated_minutes",
153 + "actual_minutes",
154 + ],
155 + )
156 + .references_unsynced(),
157 + t(
158 + "time_sessions",
159 + &[
160 + "id",
161 + "task_id",
162 + "user_id",
163 + "started_at",
164 + "ended_at",
165 + "duration_minutes",
166 + "created_at",
167 + ],
168 + ),
169 + // Also carries source_email_id -> the unsynced emails table.
170 + SyncTable::full(
171 + "attachments",
172 + &[
173 + "id",
174 + "user_id",
175 + "task_id",
176 + "project_id",
177 + "filename",
178 + "file_size",
179 + "mime_type",
180 + "blob_hash",
181 + "source_email_id",
182 + "created_at",
183 + ],
184 + )
185 + .references_unsynced(),
186 + t(
187 + "events",
188 + &[
189 + "id",
190 + "project_id",
191 + "title",
192 + "description",
193 + "start_time",
194 + "end_time",
195 + "location",
196 + "user_id",
197 + "linked_task_id",
198 + "recurrence",
199 + "recurrence_parent_id",
200 + "recurrence_rule",
201 + "contact_id",
202 + "block_type",
203 + "external_source",
204 + "external_id",
205 + "is_read_only",
206 + "snoozed_until",
207 + "reminder_offsets_seconds",
208 + ],
209 + ),
210 + t("annotations", &["id", "task_id", "timestamp", "note"]),
211 + t(
212 + "subtasks",
213 + &[
214 + "id",
215 + "task_id",
216 + "text",
217 + "is_completed",
218 + "position",
219 + "created_at",
220 + "linked_task_id",
221 + ],
222 + ),
223 + t(
224 + "task_status_tokens",
225 + &[
226 + "id",
227 + "task_id",
228 + "kind",
229 + "reference",
230 + "state",
231 + "is_primary",
232 + "position",
233 + "created_at",
234 + ],
235 + ),
236 + t(
237 + "contact_emails",
238 + &["id", "contact_id", "address", "label", "is_primary"],
239 + ),
240 + t(
241 + "contact_phones",
242 + &["id", "contact_id", "number", "label", "is_primary"],
243 + ),
244 + t(
245 + "contact_social_handles",
246 + &["id", "contact_id", "platform", "handle", "url"],
247 + ),
248 + t(
249 + "contact_custom_fields",
250 + &["id", "contact_id", "label", "value", "url"],
251 + ),
252 + t(
253 + "daily_notes",
254 + &[
255 + "id",
256 + "user_id",
257 + "note_date",
258 + "went_well",
259 + "could_improve",
260 + "is_reviewed",
261 + "reviewed_at",
262 + "created_at",
263 + "updated_at",
264 + ],
265 + ),
266 + // Independent roots (FK only to local `users`); order among them is
267 + // irrelevant, but kept identical to UPSERT_ORDER for the diff test.
268 + t(
269 + "saved_views",
270 + &[
271 + "id",
272 + "user_id",
273 + "name",
274 + "view_type",
275 + "filters",
276 + "sort_by",
277 + "sort_order",
278 + "is_pinned",
279 + "position",
280 + "created_at",
281 + "updated_at",
282 + ],
283 + ),
284 + t(
285 + "weekly_reviews",
286 + &[
287 + "id",
288 + "user_id",
289 + "week_start_date",
290 + "completed_at",
291 + "notes",
292 + "vacation_days",
293 + ],
294 + ),
295 + t(
296 + "monthly_goals",
297 + &[
298 + "id",
299 + "user_id",
300 + "month",
301 + "text",
302 + "status",
303 + "position",
304 + "created_at",
305 + "updated_at",
306 + ],
307 + ),
308 + t(
309 + "monthly_reflections",
310 + &[
311 + "id",
312 + "user_id",
313 + "month",
314 + "highlight_text",
315 + "change_text",
316 + "completed_at",
317 + ],
318 + ),
319 + ])
320 + // GO's current model; also the engine default.
321 + .conflict_strategy(ConflictStrategy::HybridLogicalClock)
322 + }
323 +
324 + #[cfg(test)]
325 + mod tests {
326 + use super::super::UPSERT_ORDER;
327 + use super::super::apply::SYNCED_COLUMNS;
328 + use super::goingson_schema;
329 +
330 + /// Behaviour-diff (M1): the declared manifest must emit exactly the columns
331 + /// GO's live triggers/apply whitelist emit today, table for table, so the
332 + /// engine-generated changelog is byte-for-byte equivalent to GO's current
333 + /// output and no table's sync policy silently changes at cutover (M2).
334 + ///
335 + /// This is the check that catches drift like a table added to GO's triggers
336 + /// (`task_status_tokens`, migration 058) but missing from the manifest.
337 + #[test]
338 + fn manifest_matches_synced_columns() {
339 + let schema = goingson_schema();
340 +
341 + // Same table set, same FK order as GO's authoritative UPSERT_ORDER.
342 + let manifest_order: Vec<&str> = schema.tables().iter().map(|t| t.name()).collect();
343 + assert_eq!(
344 + manifest_order, UPSERT_ORDER,
345 + "manifest table set/order diverged from UPSERT_ORDER"
346 + );
347 +
348 + // Same emitted columns per table as apply.rs SYNCED_COLUMNS. For
349 + // email_accounts the preserved credential columns are apply-side policy
350 + // and correctly absent from the emitted (changelog) columns.
351 + for table in schema.tables() {
352 + let expected = SYNCED_COLUMNS
353 + .iter()
354 + .find(|(name, _)| *name == table.name())
355 + .unwrap_or_else(|| {
356 + panic!("{} is in the manifest but not SYNCED_COLUMNS", table.name())
357 + })
358 + .1;
359 + assert_eq!(
360 + table.emitted_columns(),
361 + expected,
362 + "emitted columns for {} diverged from SYNCED_COLUMNS",
363 + table.name()
364 + );
365 + }
366 +
367 + // And no SYNCED_COLUMNS table is missing from the manifest.
368 + assert_eq!(
369 + schema.tables().len(),
370 + SYNCED_COLUMNS.len(),
371 + "manifest and SYNCED_COLUMNS have different table counts"
372 + );
373 + }
374 + }
@@ -22,6 +22,7 @@
22 22 mod apply;
23 23 pub(crate) mod blob_sync;
24 24 mod hlc;
25 + mod manifest;
25 26 mod pull;
26 27 mod push;
27 28 mod state;