@interop/social-core
v0.8.1
Published
Platform-neutral contacts data model, normalization, import-merge, and last-write-wins rules shared by Freewallet mobile and web
Readme
Freewallet Social Core (@interop/social-core)
Platform-neutral contacts data model, normalization, import-merge, and last-write-wins rules shared by Freewallet mobile and web.
Table of Contents
Background
Freewallet has two replicas of the same contacts feed: a React Native mobile wallet and a web app. They sync through a shared encrypted store, so they only converge if they agree, byte for byte, on three things:
- the payload shapes written into each sync envelope (
ContactData, the head payload, the revision payload), - the import-merge rules that decide how a re-imported address book folds into existing contacts, and
- the last-write-wins rule that settles concurrent edits to the same contact.
This package is the single source of truth for all three. It is pure, dependency-free, and isomorphic (browser, Node.js, React Native): no storage, no crypto, no platform APIs. Each replica keeps its own storage layer (SQLite on mobile, its own store on web) and calls into this package for the shared decisions, so the two implementations cannot drift.
Security
This package never touches secrets, key material, or the network. It operates
only on already-decrypted, in-memory plaintext contact data. The runtime type
guards (isContactData, isContactHeadPayload, isContactRevisionPayload)
exist so that one replica can validate an envelope body written by the other
implementation the moment it decrypts it, before trusting its shape.
Install
- Node.js 24+ is recommended.
PNPM
To install via PNPM:
pnpm install @interop/social-coreDevelopment
To install locally (for development):
git clone https://github.com/interop-alliance/social-core.git
cd social-core
pnpm installUsage
Settle concurrent edits to the same contact with the shared last-write-wins rule:
import { remotePayloadWins } from '@interop/social-core'
const takeRemote = remotePayloadWins(
{ updatedAt: remote.updatedAt, deviceId: remote.deviceId },
{ updatedAt: local.updatedAt, deviceId: local.deviceId }
)Normalize a source-mapped contact before storing it:
import { normalizeContact } from '@interop/social-core'
const contact = normalizeContact({
nativeId: entry.id,
givenName: entry.firstName,
familyName: entry.lastName,
phoneNumbers: entry.phones // [{ label, number }]
})
// `contact` is `ContactData`, or `null` when there is nothing worth importing.Plan a re-runnable address-book import against the contacts already stored:
import { planImportMerge } from '@interop/social-core'
const { inserts, overwrites, skips, stale } = planImportMerge(
existingRows,
incoming
)
// Apply inserts/overwrites inside your own storage transaction; an overwrite
// must keep the row's `updatedAt === createdAt` so it stays import-refreshable.
// A contact whose source id churned (Android re-linking its aggregate ids, for
// example) is matched by content -- identical content (ignoring the churn-prone
// id metadata), a shared DID alone, or name plus a shared phone or email -- so
// it rebinds to its row instead of duplicating it. `stale` lists the imported rows
// this batch never matched; the planner never deletes, so it is yours to offer
// as cleanup or ignore.Upgrade a stored contact to the current shape when you load it:
import { upgradeContactData } from '@interop/social-core'
const contact = upgradeContactData(headPayload.contact)
// Idempotent: a contact already in the current shape is returned unchanged,
// so it is safe to apply on every load. As well as the postal-address
// spellings, this re-normalizes every entry label, which repairs a row stored
// before iOS's `_$!<Home>!$_` label wrapper was stripped.Modules
| Module | Exports | Purpose |
| -------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| types | ContactData, ContactAction, ContactHeadPayload, ContactRevisionPayload | The shared contact shape and the decrypted sync-envelope payload shapes. |
| constants | CONTACTS_COLLECTION, CONTACTS_HISTORY_COLLECTION, and their specs | The wire-format collection ids and id-derivation both replicas agree on. |
| lww | remotePayloadWins | The last-write-wins tiebreak for a mutable head document. |
| normalize | normalizeLabel, normalizeContact, ContactInput | Pure normalization of a source-mapped partial contact into ContactData. |
| merge | planImportMerge | The pure insert / overwrite / skip / stale planner for a re-runnable import, with a content fallback for churned source ids. |
| validate | isContactData, isContactHeadPayload, isContactRevisionPayload | Runtime guards for a payload one replica decrypts from the other. |
| upgrade | upgradeContactData, upgradeContactHeadPayload, upgradeContactRevisionPayload | Read-path upgrade of a stored contact to the current shape. |
| dids | getDids, setDids, isDidUrl, unmangleDidUrl | The DIDs a contact carries: one predicate shared by the read and write sides of an edit. |
| seedContacts | selfContact, SELF_CONTACT_NAME, isUnlinkedSeedTwin | The self-contact a new wallet seeds itself with, and the rule a pull absorbs another replica's copy of a seed by. |
| display | initialsFor, secondaryLineFor, contactMatchesQuery, compareContactsByName, ACTION_LABELS, snapshotLines | The presentation rules both wallets render a contact, a contact list, and a revision with. |
| buildContact | buildContact, ContactFormRow | The headless half of an edit form: assembling form state plus the loaded contact into the ContactData to store. |
Contribute
PRs accepted. See CONTRIBUTING.md for editor setup (Prettier, ESLint, and EditorConfig) and how it maps to CI.
If editing the Readme, please conform to the standard-readme specification.
License
MIT License © 2026 Interop Alliance.
