Give SyncLink the twin's collection and a slot for several twins per record, and add unbind and a lookup by UID #326

Closed
opened 2026-09-30 20:30:05 +00:00 by tiagoagueda · 0 comments
Owner

SyncLink (src/postulo/plugins/models.py) ties a local record to its twin on a sync connection. Besides its owner, it holds:

  • the connection, whose deletion deletes its links;
  • a generic key to the record: content_type plus a required object_id;
  • remote_href (500), uid (200), etag, local_hash, last_synced_at and remote_gone.

It is unique on (connection, record) (synclink_one_per_record). remote_href is indexed with the connection but not unique, and there is no collection field. The API is for_record, of_model and bind, an update_or_create with the owner taken from the connection; Writing a plugin calls these three "the whole API". Plugins set remote_gone, and nothing documents clearing it. Pairings are in neither the personal export nor the import (src/postulo/core/export.py, src/postulo/core/importer.py).

The DAV plugin's improvements need three things this cannot say: which collection a twin lives in, that one record has several twins, and that a pairing was undone. A plugin cannot keep a link table of its own, because a plugin cannot own tables (#228), so they belong here.

Proposal

  1. collection: the address of the collection the twin lives in (an address book, a calendar, a task list), blank where a plugin has none.
    • A UID is unique only within its collection (RFC 6352 §5.1, RFC 4791 §4.1), so a pairing keyed on the UID (postulo/postulo-dav#7) has to say where. The same UID in two collections is two items.
    • A twin found again in another chosen collection moves with it.
  2. slot: a short key the plugin chooses, naming which of a record's twins this is. Blank means the only one, which is every row today.
    • For example, an application with a "submit" task and a "hear back" task, or an interview with an event and a task.
    • The constraint becomes unique on (connection, record, slot). for_record and bind take slot=""; of_model returns every slot.
    • slot rather than role, because a contact's role is already its job title.
  3. One remote item, one record. Make (connection, collection, uid) unique when both are set, so that nothing binds one remote item to two records on one connection by accident. bind refuses such a binding with an exception naming the record already paired, not a bare IntegrityError.
  4. API.
    • unbind(connection, record, slot="") undoes a pairing and leaves the record and the remote item alone. What it leaves behind is a decision below.
    • for_remote(connection, collection, uid) finds the link from the remote side.
    • bind clears remote_gone unless the caller passes it: a record bound again is paired again. The DAV plugin binds only records that have no link, so nothing changes for it.
    • The docstring and Writing a plugin say that setting and clearing remote_gone is the plugin's job.
  5. Migration. Existing rows get a blank collection and slot, so the new uniqueness does not apply to them until the plugin fills the collection. A re-added DAV connection can hold two links with one UID in one collection (the bug in postulo/postulo-dav#7), so that plugin settles those before it fills the collection.
  6. Admin. SyncLinkAdmin lists the collection and the slot.

Decisions

  • What unbind leaves behind. A plugin that pairs by UID (postulo/postulo-dav#7) would pair the two again on its next run, and today's DAV plugin would push the record as a new item and adopt the item as a new record.

    • (a) Delete the row, and let each plugin cope.
    • (b) Keep the row, marked as unpaired by the person, so the plugin leaves both sides alone until either is paired again. An unpaired row does not count in the uniqueness of point 3.

    Recommended: (b).

  • Pairings in the personal export. Connections are not exported, nor their secrets, and a pairing without its connection points to a server the importing instance has no credentials for. Recommended: leave pairings out and let a re-added connection re-pair by UID (postulo/postulo-dav#7). The archive format stays at 25.

  • Unique or only indexed on (connection, collection, uid). Pairing one card with a contact and a department at once would write one card into two records. Recommended: unique, with #327 saying which record an item is already paired with.

  • The slot vocabulary. Free text per plugin, or a list kept by the core. Recommended: free text, because one plugin's slots mean nothing to another.

Needed by

Done when

  • The migration keeps every existing link.
  • tests/test_syncs.py covers two slots on one record, one UID in two collections, a second record refused for one item, unbind and what it leaves, bind clearing remote_gone, and for_remote.
  • Writing a plugin describes the fields, slot, unbind and for_remote instead of "the whole API", with the rule to key a twin on its collection and UID and keep the address as a cache (RFC 4791 §5.3.2).
    • SyncLink is already on postulo.plugins.api, so tests/test_wiki_surface.py and tests/test_plugin_surface.py have no new name to find. The surface promise covers the additions all the same; they are additive, so no deprecation is needed.
  • The changelog tells plugin authors.
  • The new field labels are in the working catalogues (en, fr-fr, pt-pt, pt-br as drafts).

From the list of DAV plugin improvements of 2026-09-30.

`SyncLink` (`src/postulo/plugins/models.py`) ties a local record to its twin on a sync connection. Besides its owner, it holds: - the connection, whose deletion deletes its links; - a generic key to the record: `content_type` plus a required `object_id`; - `remote_href` (500), `uid` (200), `etag`, `local_hash`, `last_synced_at` and `remote_gone`. It is unique on (connection, record) (`synclink_one_per_record`). `remote_href` is indexed with the connection but not unique, and there is no collection field. The API is `for_record`, `of_model` and `bind`, an `update_or_create` with the owner taken from the connection; *Writing a plugin* calls these three "the whole API". Plugins set `remote_gone`, and nothing documents clearing it. Pairings are in neither the personal export nor the import (`src/postulo/core/export.py`, `src/postulo/core/importer.py`). The DAV plugin's improvements need three things this cannot say: which collection a twin lives in, that one record has several twins, and that a pairing was undone. A plugin cannot keep a link table of its own, because a plugin cannot own tables (#228), so they belong here. ## Proposal 1. **`collection`**: the address of the collection the twin lives in (an address book, a calendar, a task list), blank where a plugin has none. - A UID is unique only within its collection (RFC 6352 §5.1, RFC 4791 §4.1), so a pairing keyed on the UID (postulo/postulo-dav#7) has to say where. The same UID in two collections is two items. - A twin found again in another chosen collection moves with it. 2. **`slot`**: a short key the plugin chooses, naming which of a record's twins this is. Blank means the only one, which is every row today. - For example, an application with a "submit" task and a "hear back" task, or an interview with an event and a task. - The constraint becomes unique on (connection, record, slot). `for_record` and `bind` take `slot=""`; `of_model` returns every slot. - `slot` rather than `role`, because a contact's `role` is already its job title. 3. **One remote item, one record.** Make (connection, collection, uid) unique when both are set, so that nothing binds one remote item to two records on one connection by accident. `bind` refuses such a binding with an exception naming the record already paired, not a bare `IntegrityError`. 4. **API.** - `unbind(connection, record, slot="")` undoes a pairing and leaves the record and the remote item alone. What it leaves behind is a decision below. - `for_remote(connection, collection, uid)` finds the link from the remote side. - `bind` clears `remote_gone` unless the caller passes it: a record bound again is paired again. The DAV plugin binds only records that have no link, so nothing changes for it. - The docstring and *Writing a plugin* say that setting and clearing `remote_gone` is the plugin's job. 5. **Migration.** Existing rows get a blank collection and slot, so the new uniqueness does not apply to them until the plugin fills the collection. A re-added DAV connection can hold two links with one UID in one collection (the bug in postulo/postulo-dav#7), so that plugin settles those before it fills the collection. 6. **Admin.** `SyncLinkAdmin` lists the collection and the slot. ## Decisions - **What `unbind` leaves behind.** A plugin that pairs by UID (postulo/postulo-dav#7) would pair the two again on its next run, and today's DAV plugin would push the record as a new item and adopt the item as a new record. - (a) Delete the row, and let each plugin cope. - (b) Keep the row, marked as unpaired by the person, so the plugin leaves both sides alone until either is paired again. An unpaired row does not count in the uniqueness of point 3. Recommended: (b). - **Pairings in the personal export.** Connections are not exported, nor their secrets, and a pairing without its connection points to a server the importing instance has no credentials for. Recommended: leave pairings out and let a re-added connection re-pair by UID (postulo/postulo-dav#7). The archive format stays at 25. - **Unique or only indexed on (connection, collection, uid).** Pairing one card with a contact and a department at once would write one card into two records. Recommended: unique, with #327 saying which record an item is already paired with. - **The slot vocabulary.** Free text per plugin, or a list kept by the core. Recommended: free text, because one plugin's slots mean nothing to another. ## Needed by - postulo/postulo-dav#7: the collection, `for_remote`, `bind` clearing `remote_gone`, and an unpairing that stays. - postulo/postulo-dav#12: several tasks per application, and a task beside an interview's event. - postulo/postulo-dav#11: an interview's event beside its task, and `unbind`. - postulo/postulo-dav#8: cards from several address books, and `unbind`. - #327 builds on `unbind` and `for_remote`. ## Done when - The migration keeps every existing link. - `tests/test_syncs.py` covers two slots on one record, one UID in two collections, a second record refused for one item, `unbind` and what it leaves, `bind` clearing `remote_gone`, and `for_remote`. - *Writing a plugin* describes the fields, `slot`, `unbind` and `for_remote` instead of "the whole API", with the rule to key a twin on its collection and UID and keep the address as a cache (RFC 4791 §5.3.2). - `SyncLink` is already on `postulo.plugins.api`, so `tests/test_wiki_surface.py` and `tests/test_plugin_surface.py` have no new name to find. The surface promise covers the additions all the same; they are additive, so no deprecation is needed. - The changelog tells plugin authors. - The new field labels are in the working catalogues (en, fr-fr, pt-pt, pt-br as drafts). From the list of DAV plugin improvements of 2026-09-30.
tiagoagueda added this to the 0.5.0 milestone 2026-10-25 17:04:56 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#326
No description provided.