| … | … | @@ -44,7 +44,7 @@ import { Writer, writeTransportConfigured } from '../lib/writer.js' |
| 44 | 44 | import { SCOPE_COLLECTION, DEFAULT_SCOPE, POST_COLLECTIONS } from '../lib/scopes.js' |
| 45 | 45 | import { createMuteStore } from '../lib/mutes.js' |
| 46 | 46 | import { createBookmarkStore } from '../lib/bookmarks.js' |
| 47 | | import { listRecord, listitemRecord, ownLists, membersOf, memberRkey, listUri as mkListUri, LIST_COLLECTION, LISTITEM_COLLECTION, PURPOSES, CURATE } from '../lib/lists.js' |
| 47 | import { listRecord, listitemRecord, ownLists, membersOf, memberRkey, listUri as mkListUri, subscriptionRkey, missingMembers, LIST_COLLECTION, LISTITEM_COLLECTION, PURPOSES, CURATE } from '../lib/lists.js' |
| 48 | 48 | import { gateRecord, choiceOf, describe as describeGate, canReply, mentionsOf, listUriOf, GATE_CHOICES } from '../lib/threadgate.js' |
| 49 | 49 | import { postgateRecord, quoteChoiceOf, canQuote, describeQuoteGate, QUOTE_CHOICES } from '../lib/postgate.js' |
| 50 | 50 | import { parseListUri, resolveRoster } from '../lib/roster.js' |
| … | … | @@ -904,54 +904,88 @@ const writer = WRITE_TRANSPORT |
| 904 | 904 | } |
| 905 | 905 | }) |
| 906 | 906 | |
| 907 | | // ── ONE-TIME: TURN A HAND-PINNED ROSTER INTO A REPLICATED ONE ──────────────────────────────────────── |
| 908 | | // A node whose subscriptions came from FAVELA_ROSTER in apps.json writes that set into an |
| 909 | | // app.bsky.graph.list in its own repo, ONCE. After that the list replicates with the identity, every |
| 910 | | // device hosting the DID resolves the same roster (see the discovery above), and the env pin is dead |
| 911 | | // weight that can be deleted. |
| 907 | // ── CONVERGE THE SUBSCRIPTION LIST ACROSS EVERY DEVICE OF THIS IDENTITY ────────────────────────────── |
| 908 | // Devices that host the same DID ARE the same identity, so they should agree on what it subscribes to |
| 909 | // without anyone configuring anything. This merges each node's local roster into the identity's own |
| 910 | // app.bsky.graph.list, which then replicates like any other record. |
| 912 | 911 | // |
| 913 | | // ⛔ ONLY FROM AN EXPLICIT PIN, AND ONLY WHERE WE SIGN. `ROSTER.length` is the pinned case by |
| 914 | | // definition — a handset DERIVES its roster from hosted + tracked and so never migrates, which is also |
| 915 | | // what stops two devices racing to author the same list. `writer.state().did` confirms this node signs |
| 916 | | // for the identity whose repo we are about to write to. |
| 912 | // ⛔ GROW-ONLY UNION, NEVER A REPLACE. The first cut wrote the list wholesale, which made "who booted |
| 913 | // first" decide everyone's roster — a handset won that race against tabertron on 2026-08-25 and its |
| 914 | // derived set became authoritative for the identity (#53). A union is order-independent instead: each |
| 915 | // node contributes only what the list lacks, so any interleaving converges on the same set and there is |
| 916 | // no winner. |
| 917 | 917 | // |
| 918 | | // Runs AFTER `writer` exists: this used to sit beside the roster resolve 150 lines up, where `writer` |
| 919 | | // is still in its temporal dead zone — a ReferenceError at boot on every node, caught before shipping |
| 920 | | // rather than in a log. |
| 918 | // ⛔ DETERMINISTIC rkeys. A fresh TID per add means two devices contributing the same DID concurrently |
| 919 | // write TWO records for one membership. resolveRoster dedups by subject so it READS correctly, but the |
| 920 | // repo accumulates duplicates forever. Deriving the rkey from the subject makes the add itself |
| 921 | // idempotent: same membership, same record, second write is a no-op. |
| 921 | 922 | // |
| 922 | | // The migrated members are subscribed IMMEDIATELY rather than at the next boot, using the same runtime |
| 923 | | // addPeer that following and viewing now use. A migration that needs a restart to take effect is the |
| 924 | | // same defect this release is fixing. |
| 925 | | if (!LIST && ROSTER.length && (HOSTED_DIDS || []).length) { |
| 923 | // ⛔ CONTRIBUTE ONCE PER NODE, then treat the list as authoritative. Grow-only and "re-merge my local |
| 924 | // set every boot" together mean an unsubscribe can never stick: delete a member on one device and the |
| 925 | // next device to boot re-adds it from its own stale local set. The marker below makes contribution a |
| 926 | // one-time act, so removals survive. |
| 927 | // |
| 928 | // Steady state is ZERO writes: nothing new to contribute, nothing written. |
| 929 | const SUBSCRIPTIONS_MARKER = STORE('subscriptions-merged') |
| 930 | |
| 931 | // The hash behind the deterministic rkey (lib/lists.js explains why it must be deterministic). |
| 932 | const rkeyHash = (subject) => b4a.toString(crypto.data(b4a.from(subject)), 'hex') |
| 933 | |
| 934 | if (!LIST && (HOSTED_DIDS || []).length) { |
| 926 | 935 | const selfDid = HOSTED_DIDS[0] |
| 927 | 936 | const uri = `at://${selfDid}/${LIST_COLLECTION}/${SUBSCRIPTIONS_RKEY}` |
| 928 | 937 | ;(async () => { |
| 929 | 938 | try { |
| 930 | | const store = await getRecordStore(selfDid) |
| 931 | | if (await store.get(LIST_COLLECTION, SUBSCRIPTIONS_RKEY)) return // already migrated; discovery picks it up |
| 932 | 939 | const st = await writer.state().catch(() => null) |
| 933 | 940 | if (!st || st.did !== selfDid) return // this node does not sign for the identity — not ours to write |
| 941 | |
| 942 | // What this node knows, minus itself: hosted DIDs are subscribed by virtue of being hosted, and a |
| 943 | // list entry for yourself says nothing. |
| 944 | const local = ROSTER |
| 945 | const contributed = fs.existsSync(SUBSCRIPTIONS_MARKER) |
| 946 | const store = await getRecordStore(selfDid) |
| 947 | const listRec = await store.get(LIST_COLLECTION, SUBSCRIPTIONS_RKEY) |
| 948 | |
| 949 | if (!listRec) { |
| 950 | // Create-if-absent. A concurrent create from a sibling device is not an error: whoever loses the |
| 951 | // race still proceeds to merge, and both arrive at the same list. |
| 952 | try { |
| 953 | await writer.putRecord(LIST_COLLECTION, SUBSCRIPTIONS_RKEY, { |
| 954 | $type: LIST_COLLECTION, |
| 955 | purpose: CURATE, |
| 956 | name: 'subscriptions', |
| 957 | description: 'Identities this account subscribes to. Shared by every device of this identity.', |
| 958 | createdAt: new Date().toISOString() |
| 959 | }, { action: 'create' }) |
| 960 | } catch (e) { |
| 961 | console.log(JSON.stringify({ msg: 'subscriptions: list already created by another device, merging into it', error: e.message })) |
| 962 | } |
| 963 | } |
| 964 | |
| 965 | if (contributed) return // already gave this node's set to the list; the list is authoritative now |
| 966 | |
| 967 | // Union: add only what the list does not already have. |
| 968 | // ⛔ store.list() is an ASYNC GENERATOR yielding { rkey, record } (overlay-repo-store.js:288) — |
| 969 | // not an array and not the { records } wrapper the P2P peer returns. Awaiting it and testing |
| 970 | // Array.isArray finds nothing, silently, and every node then re-contributes every boot. |
| 971 | const already = new Set() |
| 972 | for await (const { record } of store.list(LISTITEM_COLLECTION)) { |
| 973 | if (record && record.list === uri && record.subject) already.add(record.subject) |
| 974 | } |
| 975 | const missing = missingMembers({ local, listed: [...already], selfDid }) |
| 934 | 976 | const createdAt = new Date().toISOString() |
| 935 | | await writer.putRecord(LIST_COLLECTION, SUBSCRIPTIONS_RKEY, { |
| 936 | | $type: LIST_COLLECTION, |
| 937 | | purpose: CURATE, |
| 938 | | name: 'subscriptions', |
| 939 | | description: 'Identities this account subscribes to. Migrated from FAVELA_ROSTER.', |
| 940 | | createdAt |
| 941 | | }, { action: 'create' }) |
| 942 | | const members = ROSTER.filter((d) => d !== selfDid) // hosted DIDs are subscribed regardless |
| 943 | | for (const did of members) { |
| 944 | | await writer.putRecord(LISTITEM_COLLECTION, tidNow(), { |
| 977 | for (const did of missing) { |
| 978 | await writer.putRecord(LISTITEM_COLLECTION, subscriptionRkey(did, rkeyHash), { |
| 945 | 979 | $type: LISTITEM_COLLECTION, subject: did, list: uri, createdAt |
| 946 | | }, { action: 'create' }) |
| 980 | }, { action: 'create' }).catch(() => {}) // deterministic rkey: a concurrent identical add is a no-op |
| 947 | 981 | addPeer(did).catch(() => {}) |
| 948 | 982 | } |
| 949 | | console.log(JSON.stringify({ |
| 950 | | msg: 'roster: MIGRATED FAVELA_ROSTER into a replicated list — every device hosting this DID now inherits it, and the env pin can be removed', |
| 951 | | list: uri, members: members.length |
| 952 | | })) |
| 983 | try { fs.writeFileSync(SUBSCRIPTIONS_MARKER, createdAt) } catch {} |
| 984 | if (missing.length) { |
| 985 | console.log(JSON.stringify({ msg: 'subscriptions: merged this node\'s roster into the shared list', list: uri, added: missing.length })) |
| 986 | } |
| 953 | 987 | } catch (e) { |
| 954 | | console.error(JSON.stringify({ msg: 'roster: list migration failed (the env roster still applies)', error: e.message })) |
| 988 | console.error(JSON.stringify({ msg: 'subscriptions: merge failed (this node keeps its local roster)', error: e.message })) |
| 955 | 989 | } |
| 956 | 990 | })() |
| 957 | 991 | } |