👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix issue with likes path

+257 -9
+16 -3
.claude/CONSTELLATION.md
··· 100 100 101 101 ### Union Array Elements ($type) 102 102 103 - **IMPORTANT**: When an array element is a union type (has `$type` field), constellation includes the type in the path: 103 + **IMPORTANT**: When an **array element** is a union type (has `$type` field), constellation includes the type in the path: 104 104 105 105 ``` 106 106 .items[com.deckbelcher.collection.list#cardItem].ref.scryfallUri ··· 108 108 109 109 NOT: 110 110 ``` 111 - .items[].ref.scryfallUri # WRONG for union types 111 + .items[].ref.scryfallUri # WRONG for union types in arrays 112 112 ``` 113 113 114 - This is because constellation's link extractor (`links/src/record.rs`) uses the `$type` value when present: 114 + This is because constellation's link extractor (`links/src/record.rs`) uses the `$type` value when present in array elements: 115 115 116 116 ```rust 117 117 if let Some(JsonValue::String(t)) = o.get("$type") { ··· 119 119 } else { 120 120 format!("{path}[]") // Plain array notation 121 121 } 122 + ``` 123 + 124 + ### Standalone Union Fields (NOT arrays) 125 + 126 + For union fields that are NOT in arrays, just use the normal path without `[$type]`: 127 + 128 + ``` 129 + .subject.ref.oracleUri # Correct for non-array union field 130 + ``` 131 + 132 + NOT: 133 + ``` 134 + .subject[some.type#variant].ref.oracleUri # WRONG - [$type] is for arrays only 122 135 ``` 123 136 124 137 ## DeckBelcher-Specific Paths
+236
src/lib/__tests__/hash-to-rkey.test.ts
··· 1 + /** 2 + * Property-based tests for hashToRkey 3 + * 4 + * Tests the deterministic rkey generation used for like records. 5 + * Uses fast-check for generative testing across the input space. 6 + * 7 + * Note: Key order independence only applies to top-level keys. 8 + * Nested objects are not recursively sorted. 9 + */ 10 + 11 + import fc from "fast-check"; 12 + import { describe, expect, it } from "vitest"; 13 + import { hashToRkey } from "../atproto-client"; 14 + 15 + const RKEY_LENGTH = 43; 16 + const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; 17 + 18 + describe("hashToRkey", () => { 19 + describe("output format", () => { 20 + it("always produces 43-character base64url string for flat objects", async () => { 21 + await fc.assert( 22 + fc.asyncProperty( 23 + fc.record({ 24 + a: fc.string(), 25 + b: fc.integer(), 26 + c: fc.boolean(), 27 + }), 28 + async (obj) => { 29 + const result = await hashToRkey(obj); 30 + expect(result).toHaveLength(RKEY_LENGTH); 31 + expect(result).toMatch(BASE64URL_PATTERN); 32 + }, 33 + ), 34 + { numRuns: 200 }, 35 + ); 36 + }); 37 + 38 + it("always produces 43-character base64url string for strings", async () => { 39 + await fc.assert( 40 + fc.asyncProperty(fc.string(), async (str) => { 41 + const result = await hashToRkey(str); 42 + expect(result).toHaveLength(RKEY_LENGTH); 43 + expect(result).toMatch(BASE64URL_PATTERN); 44 + }), 45 + { numRuns: 200 }, 46 + ); 47 + }); 48 + 49 + it("never contains base64 padding or standard base64 chars", async () => { 50 + await fc.assert( 51 + fc.asyncProperty( 52 + fc.record({ key: fc.string(), value: fc.integer() }), 53 + async (val) => { 54 + const result = await hashToRkey(val); 55 + expect(result).not.toContain("="); 56 + expect(result).not.toContain("+"); 57 + expect(result).not.toContain("/"); 58 + }, 59 + ), 60 + { numRuns: 200 }, 61 + ); 62 + }); 63 + }); 64 + 65 + describe("determinism", () => { 66 + it("produces same output for same object input", async () => { 67 + await fc.assert( 68 + fc.asyncProperty( 69 + fc.record({ x: fc.string(), y: fc.integer() }), 70 + async (obj) => { 71 + const result1 = await hashToRkey(obj); 72 + const result2 = await hashToRkey(obj); 73 + expect(result1).toBe(result2); 74 + }, 75 + ), 76 + { numRuns: 100 }, 77 + ); 78 + }); 79 + 80 + it("produces same output for structurally identical objects", async () => { 81 + await fc.assert( 82 + fc.asyncProperty( 83 + fc.record({ 84 + a: fc.integer(), 85 + b: fc.string(), 86 + c: fc.boolean(), 87 + }), 88 + async (template) => { 89 + const obj1 = { ...template }; 90 + const obj2 = { ...template }; 91 + const result1 = await hashToRkey(obj1); 92 + const result2 = await hashToRkey(obj2); 93 + expect(result1).toBe(result2); 94 + }, 95 + ), 96 + { numRuns: 100 }, 97 + ); 98 + }); 99 + }); 100 + 101 + describe("collision resistance", () => { 102 + it("produces different output for different integers", async () => { 103 + await fc.assert( 104 + fc.asyncProperty( 105 + fc.integer().filter((n) => n !== 0), 106 + async (a) => { 107 + const result1 = await hashToRkey({ n: a }); 108 + const result2 = await hashToRkey({ n: a + 1 }); 109 + expect(result1).not.toBe(result2); 110 + }, 111 + ), 112 + { numRuns: 100 }, 113 + ); 114 + }); 115 + 116 + it("produces different output for different strings", async () => { 117 + await fc.assert( 118 + fc.asyncProperty(fc.string({ minLength: 1 }), async (a) => { 119 + const result1 = await hashToRkey({ s: a }); 120 + const result2 = await hashToRkey({ s: `${a}x` }); 121 + expect(result1).not.toBe(result2); 122 + }), 123 + { numRuns: 100 }, 124 + ); 125 + }); 126 + 127 + it("produces different output for objects with different values", async () => { 128 + await fc.assert( 129 + fc.asyncProperty(fc.integer(), async (n) => { 130 + const result1 = await hashToRkey({ value: n }); 131 + const result2 = await hashToRkey({ value: n + 1 }); 132 + expect(result1).not.toBe(result2); 133 + }), 134 + { numRuns: 100 }, 135 + ); 136 + }); 137 + }); 138 + 139 + describe("key order independence (top-level only)", () => { 140 + it("same hash regardless of key insertion order", async () => { 141 + const obj1 = { a: 1, b: 2, c: 3 }; 142 + const obj2 = { c: 3, b: 2, a: 1 }; 143 + const obj3 = { b: 2, a: 1, c: 3 }; 144 + 145 + const result1 = await hashToRkey(obj1); 146 + const result2 = await hashToRkey(obj2); 147 + const result3 = await hashToRkey(obj3); 148 + 149 + expect(result1).toBe(result2); 150 + expect(result2).toBe(result3); 151 + }); 152 + 153 + it("same hash for shuffled key order with arbitrary values", async () => { 154 + await fc.assert( 155 + fc.asyncProperty( 156 + fc.integer(), 157 + fc.string(), 158 + fc.boolean(), 159 + async (a, b, c) => { 160 + const obj1 = { a, b, c }; 161 + const obj2 = { c, b, a }; 162 + const obj3 = { b, a, c }; 163 + 164 + const result1 = await hashToRkey(obj1); 165 + const result2 = await hashToRkey(obj2); 166 + const result3 = await hashToRkey(obj3); 167 + 168 + expect(result1).toBe(result2); 169 + expect(result2).toBe(result3); 170 + }, 171 + ), 172 + { numRuns: 100 }, 173 + ); 174 + }); 175 + 176 + it("like subjects hash identically regardless of construction", async () => { 177 + await fc.assert( 178 + fc.asyncProperty(fc.string(), fc.string(), async (uri, cid) => { 179 + const subject1 = { 180 + $type: "com.deckbelcher.social.like#recordSubject", 181 + ref: { uri, cid }, 182 + }; 183 + const subject2 = { 184 + ref: { uri, cid }, 185 + $type: "com.deckbelcher.social.like#recordSubject", 186 + }; 187 + 188 + const result1 = await hashToRkey(subject1); 189 + const result2 = await hashToRkey(subject2); 190 + 191 + expect(result1).toBe(result2); 192 + }), 193 + { numRuns: 100 }, 194 + ); 195 + }); 196 + }); 197 + 198 + describe("edge cases", () => { 199 + it("handles empty object", async () => { 200 + const result = await hashToRkey({}); 201 + expect(result).toHaveLength(RKEY_LENGTH); 202 + expect(result).toMatch(BASE64URL_PATTERN); 203 + }); 204 + 205 + it("handles empty string", async () => { 206 + const result = await hashToRkey(""); 207 + expect(result).toHaveLength(RKEY_LENGTH); 208 + expect(result).toMatch(BASE64URL_PATTERN); 209 + }); 210 + 211 + it("handles empty array", async () => { 212 + const result = await hashToRkey([]); 213 + expect(result).toHaveLength(RKEY_LENGTH); 214 + expect(result).toMatch(BASE64URL_PATTERN); 215 + }); 216 + 217 + it("handles deeply nested objects", async () => { 218 + const deep = { a: { b: { c: { d: { e: { f: "value" } } } } } }; 219 + const result = await hashToRkey(deep); 220 + expect(result).toHaveLength(RKEY_LENGTH); 221 + expect(result).toMatch(BASE64URL_PATTERN); 222 + }); 223 + 224 + it("handles objects with special characters in values", async () => { 225 + const obj = { 226 + emoji: "emoji", 227 + unicode: "unicode", 228 + newline: "line1\nline2", 229 + tab: "col1\tcol2", 230 + }; 231 + const result = await hashToRkey(obj); 232 + expect(result).toHaveLength(RKEY_LENGTH); 233 + expect(result).toMatch(BASE64URL_PATTERN); 234 + }); 235 + }); 236 + });
+2 -1
src/lib/atproto-client.ts
··· 433 433 * Hash an object to a deterministic rkey using SHA-256 + base64url. 434 434 * Full hash (43 chars) for maximum collision resistance. 435 435 * Valid rkey chars: A-Za-z0-9.-_:~ (base64url uses A-Za-z0-9-_) 436 + * Sorts keys to ensure hash is independent of object key insertion order. 436 437 */ 437 438 export async function hashToRkey(obj: unknown): Promise<Rkey> { 438 - const json = JSON.stringify(obj); 439 + const json = JSON.stringify(obj, Object.keys(obj as object).sort()); 439 440 const encoder = new TextEncoder(); 440 441 const data = encoder.encode(json); 441 442 const hashBuffer = await crypto.subtle.digest("SHA-256", data);
+3 -5
src/lib/constellation-client.ts
··· 18 18 // Future: cards in decks (also uses oracleUri for aggregation) 19 19 export const DECK_LIST_CARD_PATH = ".cards[].ref.oracleUri"; 20 20 21 - // Like paths (subject is a union, so includes $type in path) 22 - export const LIKE_CARD_PATH = 23 - ".subject[com.deckbelcher.social.like#cardSubject].ref.oracleUri"; 24 - export const LIKE_RECORD_PATH = 25 - ".subject[com.deckbelcher.social.like#recordSubject].ref.uri"; 21 + // Like paths (subject is a union but NOT an array, so no [$type] notation) 22 + export const LIKE_CARD_PATH = ".subject.ref.oracleUri"; 23 + export const LIKE_RECORD_PATH = ".subject.ref.uri"; 26 24 27 25 export const COLLECTION_LIST_NSID = "com.deckbelcher.collection.list"; 28 26 export const DECK_LIST_NSID = "com.deckbelcher.deck.list";