A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

fix: correct MST layer ordering (root at highest layer, not layer 0)

ATProto MST spec: "Layers are counted from the bottom of the tree,
starting with zero." This means:
- Layer 0 is at the BOTTOM
- Root is at the HIGHEST layer (max depth of any key)
- Subtrees go DOWN to lower layers

Previous implementation had this inverted, causing "depths are out of
order" validation errors in pdsls.

Also includes:
- MST nodes must include l and t fields (even when null) per ATProto schema
- handleGetRepo now only includes blocks reachable from current commit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+81 -17
src
+81 -17
src/pds.js
··· 558 return null 559 } 560 561 - // Build entries with pre-computed depths 562 const entries = [] 563 for (const r of records) { 564 const key = `${r.collection}/${r.rkey}` 565 entries.push({ 566 key, 567 keyBytes: new TextEncoder().encode(key), 568 cid: r.cid, 569 - depth: await getKeyDepth(key) 570 }) 571 } 572 573 - return this.buildTree(entries, 0) 574 } 575 576 async buildTree(entries, layer) { 577 if (entries.length === 0) return null 578 579 - // Separate entries for this layer vs deeper layers 580 const thisLayer = [] 581 let leftSubtree = [] 582 583 for (const entry of entries) { 584 - if (entry.depth > layer) { 585 leftSubtree.push(entry) 586 } else { 587 - // Process accumulated left subtree 588 if (leftSubtree.length > 0) { 589 - const leftCid = await this.buildTree(leftSubtree, layer + 1) 590 thisLayer.push({ type: 'subtree', cid: leftCid }) 591 leftSubtree = [] 592 } ··· 596 597 // Handle remaining left subtree 598 if (leftSubtree.length > 0) { 599 - const leftCid = await this.buildTree(leftSubtree, layer + 1) 600 thisLayer.push({ type: 'subtree', cid: leftCid }) 601 } 602 ··· 621 const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes) 622 const keySuffix = keyBytes.slice(prefixLen) 623 624 const e = { 625 p: prefixLen, 626 k: keySuffix, 627 v: new CID(cidToBytes(item.entry.cid)), 628 - t: null // Always include t field (set later if subtree exists) 629 } 630 631 node.e.push(e) ··· 633 } 634 } 635 636 - // Always include left pointer (can be null) 637 node.l = leftCid ? new CID(cidToBytes(leftCid)) : null 638 639 // Encode node with proper MST CBOR format ··· 675 for (const item of val) encode(item) 676 } else if (typeof val === 'object') { 677 // Sort keys for deterministic encoding (DAG-CBOR style) 678 - // Include null values, only exclude undefined 679 const keys = Object.keys(val).filter(k => val[k] !== undefined) 680 keys.sort((a, b) => { 681 // DAG-CBOR: sort by length first, then lexicographically ··· 1420 if (commits.length === 0) { 1421 return Response.json({ error: 'repo not found' }, { status: 404 }) 1422 } 1423 - const blocks = this.sql.exec(`SELECT cid, data FROM blocks`).toArray() 1424 - const blocksForCar = blocks.map(b => ({ 1425 - cid: b.cid, 1426 - data: new Uint8Array(b.data) 1427 - })) 1428 - const car = buildCarFile(commits[0].cid, blocksForCar) 1429 return new Response(car, { 1430 headers: { 'content-type': 'application/vnd.ipld.car' } 1431 })
··· 558 return null 559 } 560 561 + // Build entries with pre-computed depths (heights) 562 + // In ATProto MST, "height" determines which layer a key belongs to 563 + // Layer 0 is at the BOTTOM, root is at the highest layer 564 const entries = [] 565 + let maxDepth = 0 566 for (const r of records) { 567 const key = `${r.collection}/${r.rkey}` 568 + const depth = await getKeyDepth(key) 569 + maxDepth = Math.max(maxDepth, depth) 570 entries.push({ 571 key, 572 keyBytes: new TextEncoder().encode(key), 573 cid: r.cid, 574 + depth 575 }) 576 } 577 578 + // Start building from the root (highest layer) going down to layer 0 579 + return this.buildTree(entries, maxDepth) 580 } 581 582 async buildTree(entries, layer) { 583 if (entries.length === 0) return null 584 585 + // Separate entries for this layer vs lower layers (subtrees) 586 + // Keys with depth == layer stay at this node 587 + // Keys with depth < layer go into subtrees (going down toward layer 0) 588 const thisLayer = [] 589 let leftSubtree = [] 590 591 for (const entry of entries) { 592 + if (entry.depth < layer) { 593 + // This entry belongs to a lower layer - accumulate for subtree 594 leftSubtree.push(entry) 595 } else { 596 + // This entry belongs at current layer (depth == layer) 597 + // Process accumulated left subtree first 598 if (leftSubtree.length > 0) { 599 + const leftCid = await this.buildTree(leftSubtree, layer - 1) 600 thisLayer.push({ type: 'subtree', cid: leftCid }) 601 leftSubtree = [] 602 } ··· 606 607 // Handle remaining left subtree 608 if (leftSubtree.length > 0) { 609 + const leftCid = await this.buildTree(leftSubtree, layer - 1) 610 thisLayer.push({ type: 'subtree', cid: leftCid }) 611 } 612 ··· 631 const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes) 632 const keySuffix = keyBytes.slice(prefixLen) 633 634 + // ATProto requires t field to be present (can be null) 635 const e = { 636 p: prefixLen, 637 k: keySuffix, 638 v: new CID(cidToBytes(item.entry.cid)), 639 + t: null // Will be updated if there's a subtree 640 } 641 642 node.e.push(e) ··· 644 } 645 } 646 647 + // ATProto requires l field to be present (can be null) 648 node.l = leftCid ? new CID(cidToBytes(leftCid)) : null 649 650 // Encode node with proper MST CBOR format ··· 686 for (const item of val) encode(item) 687 } else if (typeof val === 'object') { 688 // Sort keys for deterministic encoding (DAG-CBOR style) 689 + // Include null values (ATProto MST requires l and t fields even when null) 690 const keys = Object.keys(val).filter(k => val[k] !== undefined) 691 keys.sort((a, b) => { 692 // DAG-CBOR: sort by length first, then lexicographically ··· 1431 if (commits.length === 0) { 1432 return Response.json({ error: 'repo not found' }, { status: 404 }) 1433 } 1434 + 1435 + // Only include blocks reachable from the current commit 1436 + const commitCid = commits[0].cid 1437 + const neededCids = new Set() 1438 + 1439 + // Helper to get block data 1440 + const getBlock = (cid) => { 1441 + const rows = this.sql.exec(`SELECT data FROM blocks WHERE cid = ?`, cid).toArray() 1442 + return rows.length > 0 ? new Uint8Array(rows[0].data) : null 1443 + } 1444 + 1445 + // Collect all reachable blocks starting from commit 1446 + const collectBlocks = (cid) => { 1447 + if (neededCids.has(cid)) return 1448 + neededCids.add(cid) 1449 + 1450 + const data = getBlock(cid) 1451 + if (!data) return 1452 + 1453 + // Decode CBOR to find CID references 1454 + try { 1455 + const decoded = cborDecode(data) 1456 + if (decoded && typeof decoded === 'object') { 1457 + // Commit object - follow 'data' (MST root) 1458 + if (decoded.data instanceof Uint8Array) { 1459 + collectBlocks(cidToString(decoded.data)) 1460 + } 1461 + // MST node - follow 'l' and entries' 'v' and 't' 1462 + if (decoded.l instanceof Uint8Array) { 1463 + collectBlocks(cidToString(decoded.l)) 1464 + } 1465 + if (Array.isArray(decoded.e)) { 1466 + for (const entry of decoded.e) { 1467 + if (entry.v instanceof Uint8Array) { 1468 + collectBlocks(cidToString(entry.v)) 1469 + } 1470 + if (entry.t instanceof Uint8Array) { 1471 + collectBlocks(cidToString(entry.t)) 1472 + } 1473 + } 1474 + } 1475 + } 1476 + } catch (e) { 1477 + // Not a structured block, that's fine 1478 + } 1479 + } 1480 + 1481 + collectBlocks(commitCid) 1482 + 1483 + // Build CAR with only needed blocks 1484 + const blocksForCar = [] 1485 + for (const cid of neededCids) { 1486 + const data = getBlock(cid) 1487 + if (data) { 1488 + blocksForCar.push({ cid, data }) 1489 + } 1490 + } 1491 + 1492 + const car = buildCarFile(commitCid, blocksForCar) 1493 return new Response(car, { 1494 headers: { 'content-type': 'application/vnd.ipld.car' } 1495 })