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 558 return null 559 559 } 560 560 561 - // Build entries with pre-computed depths 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 562 564 const entries = [] 565 + let maxDepth = 0 563 566 for (const r of records) { 564 567 const key = `${r.collection}/${r.rkey}` 568 + const depth = await getKeyDepth(key) 569 + maxDepth = Math.max(maxDepth, depth) 565 570 entries.push({ 566 571 key, 567 572 keyBytes: new TextEncoder().encode(key), 568 573 cid: r.cid, 569 - depth: await getKeyDepth(key) 574 + depth 570 575 }) 571 576 } 572 577 573 - return this.buildTree(entries, 0) 578 + // Start building from the root (highest layer) going down to layer 0 579 + return this.buildTree(entries, maxDepth) 574 580 } 575 581 576 582 async buildTree(entries, layer) { 577 583 if (entries.length === 0) return null 578 584 579 - // Separate entries for this layer vs deeper layers 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) 580 588 const thisLayer = [] 581 589 let leftSubtree = [] 582 590 583 591 for (const entry of entries) { 584 - if (entry.depth > layer) { 592 + if (entry.depth < layer) { 593 + // This entry belongs to a lower layer - accumulate for subtree 585 594 leftSubtree.push(entry) 586 595 } else { 587 - // Process accumulated left subtree 596 + // This entry belongs at current layer (depth == layer) 597 + // Process accumulated left subtree first 588 598 if (leftSubtree.length > 0) { 589 - const leftCid = await this.buildTree(leftSubtree, layer + 1) 599 + const leftCid = await this.buildTree(leftSubtree, layer - 1) 590 600 thisLayer.push({ type: 'subtree', cid: leftCid }) 591 601 leftSubtree = [] 592 602 } ··· 596 606 597 607 // Handle remaining left subtree 598 608 if (leftSubtree.length > 0) { 599 - const leftCid = await this.buildTree(leftSubtree, layer + 1) 609 + const leftCid = await this.buildTree(leftSubtree, layer - 1) 600 610 thisLayer.push({ type: 'subtree', cid: leftCid }) 601 611 } 602 612 ··· 621 631 const prefixLen = commonPrefixLen(prevKeyBytes, keyBytes) 622 632 const keySuffix = keyBytes.slice(prefixLen) 623 633 634 + // ATProto requires t field to be present (can be null) 624 635 const e = { 625 636 p: prefixLen, 626 637 k: keySuffix, 627 638 v: new CID(cidToBytes(item.entry.cid)), 628 - t: null // Always include t field (set later if subtree exists) 639 + t: null // Will be updated if there's a subtree 629 640 } 630 641 631 642 node.e.push(e) ··· 633 644 } 634 645 } 635 646 636 - // Always include left pointer (can be null) 647 + // ATProto requires l field to be present (can be null) 637 648 node.l = leftCid ? new CID(cidToBytes(leftCid)) : null 638 649 639 650 // Encode node with proper MST CBOR format ··· 675 686 for (const item of val) encode(item) 676 687 } else if (typeof val === 'object') { 677 688 // Sort keys for deterministic encoding (DAG-CBOR style) 678 - // Include null values, only exclude undefined 689 + // Include null values (ATProto MST requires l and t fields even when null) 679 690 const keys = Object.keys(val).filter(k => val[k] !== undefined) 680 691 keys.sort((a, b) => { 681 692 // DAG-CBOR: sort by length first, then lexicographically ··· 1420 1431 if (commits.length === 0) { 1421 1432 return Response.json({ error: 'repo not found' }, { status: 404 }) 1422 1433 } 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) 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) 1429 1493 return new Response(car, { 1430 1494 headers: { 'content-type': 'application/vnd.ipld.car' } 1431 1495 })