+2
-2
api/.spectral.yaml
+2
-2
api/.spectral.yaml
···
1
1
# Spectral linting for OpenAPI specs
2
-
#
2
+
#
3
3
# Usage:
4
4
# spectral lint <path-to-openapi-spec>
5
5
# spectral lint openapi.json --ruleset .spectral.yaml
6
6
#
7
7
# To lint the generated OpenAPI spec from the API:
8
-
# curl "http://localhost:3000/xrpc/social.slices.slice.openapi?slice=at://did:plc:example/social.slices.slice/example" | spectral lint -
8
+
# curl "http://localhost:3000/xrpc/network.slices.openapi?slice=at://did:plc:example/network.slices.slice/example" | spectral lint -
9
9
#
10
10
extends: ["spectral:oas"]
+22
api/.sqlx/query-112e2cbb7ee10d0ec1261e8beebda6c126bf23dea5a8f3fe9f7e9952807a478f.json
+22
api/.sqlx/query-112e2cbb7ee10d0ec1261e8beebda6c126bf23dea5a8f3fe9f7e9952807a478f.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT DISTINCT json->>'nsid' as collection_nsid\n FROM record\n WHERE collection = 'network.slices.lexicon'\n AND json->>'slice' = $1\n AND json->>'nsid' IS NOT NULL\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n ORDER BY json->>'nsid'\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "collection_nsid",
9
+
"type_info": "Text"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
null
19
+
]
20
+
},
21
+
"hash": "112e2cbb7ee10d0ec1261e8beebda6c126bf23dea5a8f3fe9f7e9952807a478f"
22
+
}
-22
api/.sqlx/query-4895a6217c253c74fae4332b31dabe04d0840ee1966d6fdb4c3dee6d2c87ad1b.json
-22
api/.sqlx/query-4895a6217c253c74fae4332b31dabe04d0840ee1966d6fdb4c3dee6d2c87ad1b.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT COUNT(*) as count\n FROM record\n WHERE collection = 'social.slices.lexicon'\n AND json->>'slice' = $1\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n ",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "count",
9
-
"type_info": "Int8"
10
-
}
11
-
],
12
-
"parameters": {
13
-
"Left": [
14
-
"Text"
15
-
]
16
-
},
17
-
"nullable": [
18
-
null
19
-
]
20
-
},
21
-
"hash": "4895a6217c253c74fae4332b31dabe04d0840ee1966d6fdb4c3dee6d2c87ad1b"
22
-
}
+34
api/.sqlx/query-5fb1c2c7b29bfd9614e24fd391e52ca09f805ecdee4b28a4891bc527ab0d915a.json
+34
api/.sqlx/query-5fb1c2c7b29bfd9614e24fd391e52ca09f805ecdee4b28a4891bc527ab0d915a.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n WITH slice_collections AS (\n SELECT DISTINCT\n json->>'nsid' as collection_nsid\n FROM record\n WHERE collection = 'network.slices.lexicon'\n AND json->>'slice' = $1\n AND json->>'nsid' IS NOT NULL\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n )\n SELECT\n r.collection,\n COUNT(*) as record_count,\n COUNT(DISTINCT r.did) as unique_actors\n FROM record r\n INNER JOIN slice_collections sc ON r.collection = sc.collection_nsid\n WHERE r.slice_uri = $1\n GROUP BY r.collection\n ORDER BY r.collection\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "collection",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "record_count",
14
+
"type_info": "Int8"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "unique_actors",
19
+
"type_info": "Int8"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Text"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
null,
30
+
null
31
+
]
32
+
},
33
+
"hash": "5fb1c2c7b29bfd9614e24fd391e52ca09f805ecdee4b28a4891bc527ab0d915a"
34
+
}
+22
api/.sqlx/query-6fa4f18b67c4dc5b175f353c6c03c8a77e42c5013e3deb5ce32afdcba0d150ab.json
+22
api/.sqlx/query-6fa4f18b67c4dc5b175f353c6c03c8a77e42c5013e3deb5ce32afdcba0d150ab.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT COUNT(*) as count\n FROM record\n WHERE collection = 'network.slices.lexicon'\n AND json->>'slice' = $1\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "count",
9
+
"type_info": "Int8"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
null
19
+
]
20
+
},
21
+
"hash": "6fa4f18b67c4dc5b175f353c6c03c8a77e42c5013e3deb5ce32afdcba0d150ab"
22
+
}
-22
api/.sqlx/query-88080d24df604cfdcd827c8201a37414cf6ebedc5e021f67d684806bcb6b1146.json
-22
api/.sqlx/query-88080d24df604cfdcd827c8201a37414cf6ebedc5e021f67d684806bcb6b1146.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n WITH slice_collections AS (\n SELECT DISTINCT\n json->>'nsid' as collection_nsid\n FROM record\n WHERE collection = 'social.slices.lexicon'\n AND json->>'slice' = $1\n AND json->>'nsid' IS NOT NULL\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n )\n SELECT COUNT(*) as count\n FROM record r\n INNER JOIN slice_collections sc ON r.collection = sc.collection_nsid\n WHERE r.slice_uri = $1\n ",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "count",
9
-
"type_info": "Int8"
10
-
}
11
-
],
12
-
"parameters": {
13
-
"Left": [
14
-
"Text"
15
-
]
16
-
},
17
-
"nullable": [
18
-
null
19
-
]
20
-
},
21
-
"hash": "88080d24df604cfdcd827c8201a37414cf6ebedc5e021f67d684806bcb6b1146"
22
-
}
-34
api/.sqlx/query-940380fe9b34d64b3d700032ab87b9f0e31ecd04a40e033aee2be995b3c54b28.json
-34
api/.sqlx/query-940380fe9b34d64b3d700032ab87b9f0e31ecd04a40e033aee2be995b3c54b28.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n WITH slice_collections AS (\n SELECT DISTINCT\n json->>'nsid' as collection_nsid\n FROM record\n WHERE collection = 'social.slices.lexicon'\n AND json->>'slice' = $1\n AND json->>'nsid' IS NOT NULL\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n )\n SELECT\n r.collection,\n COUNT(*) as record_count,\n COUNT(DISTINCT r.did) as unique_actors\n FROM record r\n INNER JOIN slice_collections sc ON r.collection = sc.collection_nsid\n WHERE r.slice_uri = $1\n GROUP BY r.collection\n ORDER BY r.collection\n ",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "collection",
9
-
"type_info": "Text"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "record_count",
14
-
"type_info": "Int8"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "unique_actors",
19
-
"type_info": "Int8"
20
-
}
21
-
],
22
-
"parameters": {
23
-
"Left": [
24
-
"Text"
25
-
]
26
-
},
27
-
"nullable": [
28
-
false,
29
-
null,
30
-
null
31
-
]
32
-
},
33
-
"hash": "940380fe9b34d64b3d700032ab87b9f0e31ecd04a40e033aee2be995b3c54b28"
34
-
}
-22
api/.sqlx/query-e583ef70eb5262bb062a4e1bab103e99fd2757e4086aef695098861151a9fb75.json
-22
api/.sqlx/query-e583ef70eb5262bb062a4e1bab103e99fd2757e4086aef695098861151a9fb75.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT DISTINCT json->>'nsid' as collection_nsid\n FROM record\n WHERE collection = 'social.slices.lexicon'\n AND json->>'slice' = $1\n AND json->>'nsid' IS NOT NULL\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n ORDER BY json->>'nsid'\n ",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "collection_nsid",
9
-
"type_info": "Text"
10
-
}
11
-
],
12
-
"parameters": {
13
-
"Left": [
14
-
"Text"
15
-
]
16
-
},
17
-
"nullable": [
18
-
null
19
-
]
20
-
},
21
-
"hash": "e583ef70eb5262bb062a4e1bab103e99fd2757e4086aef695098861151a9fb75"
22
-
}
+22
api/.sqlx/query-e62f397eb38f35e17988429c36de83f0e299c7def29dd1452706563ee58f9e3f.json
+22
api/.sqlx/query-e62f397eb38f35e17988429c36de83f0e299c7def29dd1452706563ee58f9e3f.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n WITH slice_collections AS (\n SELECT DISTINCT\n json->>'nsid' as collection_nsid\n FROM record\n WHERE collection = 'network.slices.lexicon'\n AND json->>'slice' = $1\n AND json->>'nsid' IS NOT NULL\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n )\n SELECT COUNT(*) as count\n FROM record r\n INNER JOIN slice_collections sc ON r.collection = sc.collection_nsid\n WHERE r.slice_uri = $1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "count",
9
+
"type_info": "Int8"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
null
19
+
]
20
+
},
21
+
"hash": "e62f397eb38f35e17988429c36de83f0e299c7def29dd1452706563ee58f9e3f"
22
+
}
+2
-2
api/.sqlx/query-fd7e15432f1c5e2b04ab38b6828bc3862e6e2b4e6d3b45a3e5aef024e815c358.json
api/.sqlx/query-732081a59997cde317e535cf85e281f4502e4af3a4704a84a615a5044f48f169.json
+2
-2
api/.sqlx/query-fd7e15432f1c5e2b04ab38b6828bc3862e6e2b4e6d3b45a3e5aef024e815c358.json
api/.sqlx/query-732081a59997cde317e535cf85e281f4502e4af3a4704a84a615a5044f48f169.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT json->>'domain' as domain\n FROM record\n WHERE collection = 'social.slices.slice'\n AND uri = $1\n ",
3
+
"query": "\n SELECT json->>'domain' as domain\n FROM record\n WHERE collection = 'network.slices.slice'\n AND uri = $1\n ",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
18
18
null
19
19
]
20
20
},
21
-
"hash": "fd7e15432f1c5e2b04ab38b6828bc3862e6e2b4e6d3b45a3e5aef024e815c358"
21
+
"hash": "732081a59997cde317e535cf85e281f4502e4af3a4704a84a615a5044f48f169"
22
22
}
+51
-44
api/scripts/generate_typescript.ts
+51
-44
api/scripts/generate_typescript.ts
···
30
30
const lexiconsInput = Deno.args[0] || "";
31
31
const sliceUri =
32
32
Deno.args[1] ||
33
-
"at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lx5zq4t56s2q";
33
+
"at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z";
34
34
35
35
if (!lexiconsInput) {
36
36
console.error("No lexicon data provided");
···
46
46
47
47
// Generate usage example based on available lexicons
48
48
function generateUsageExample(): string {
49
-
// Find the first non-social.slices lexicon that has a record type
49
+
// Find the first non-network.slices lexicon that has a record type
50
50
const nonSlicesLexicon = lexicons.find(
51
51
(lex) =>
52
52
lex.id &&
53
-
!lex.id.startsWith("social.slices.") &&
53
+
!lex.id.startsWith("network.slices.") &&
54
54
lex.definitions &&
55
55
Object.values(lex.definitions).some((def) => def.type === "record")
56
56
);
···
85
85
* });
86
86
*
87
87
* // Use slice-level methods for cross-collection queries with type safety
88
-
* const sliceRecords = await client.social.slices.slice.getSliceRecords<${nsidToPascalCase(nonSlicesLexicon.id)}>({
88
+
* const sliceRecords = await client.network.slices.slice.getSliceRecords<${nsidToPascalCase(
89
+
nonSlicesLexicon.id
90
+
)}>({
89
91
* where: {
90
92
* collection: { eq: '${nonSlicesLexicon.id}' }
91
93
* }
92
94
* });
93
95
*
94
96
* // Search across multiple collections using union types
95
-
* const multiCollectionRecords = await client.social.slices.slice.getSliceRecords<${nsidToPascalCase(nonSlicesLexicon.id)} | AppBskyActorProfile>({
97
+
* const multiCollectionRecords = await client.network.slices.slice.getSliceRecords<${nsidToPascalCase(
98
+
nonSlicesLexicon.id
99
+
)} | AppBskyActorProfile>({
96
100
* where: {
97
101
* collection: { in: ['${nonSlicesLexicon.id}', 'app.bsky.actor.profile'] },
98
102
* text: { contains: 'example search term' },
···
106
110
* \`\`\`
107
111
*/`;
108
112
} else {
109
-
// Fallback: find any lexicon with a record type (including social.slices)
113
+
// Fallback: find any lexicon with a record type (including network.slices)
110
114
const anyRecordLexicon = lexicons.find(
111
115
(lex) =>
112
116
lex.definitions &&
···
141
145
* });
142
146
*
143
147
* // Cross-collection operations using base client with type safety
144
-
* const multiCollectionResults = await client.getSliceRecords<${nsidToPascalCase(anyRecordLexicon.id)}>({
148
+
* const multiCollectionResults = await client.getSliceRecords<${nsidToPascalCase(
149
+
anyRecordLexicon.id
150
+
)}>({
145
151
* where: {
146
152
* collection: { eq: '${anyRecordLexicon.id}' }
147
153
* },
···
405
411
sourceFile.addInterface({
406
412
name: "GetJobLogsResponse",
407
413
isExported: true,
408
-
properties: [
409
-
{ name: "logs", type: "LogEntry[]" },
410
-
],
414
+
properties: [{ name: "logs", type: "LogEntry[]" }],
411
415
});
412
416
413
417
sourceFile.addInterface({
414
418
name: "GetJetstreamLogsParams",
415
419
isExported: true,
416
-
properties: [
417
-
{ name: "limit", type: "number", hasQuestionToken: true },
418
-
],
420
+
properties: [{ name: "limit", type: "number", hasQuestionToken: true }],
419
421
});
420
422
421
423
sourceFile.addInterface({
422
424
name: "GetJetstreamLogsResponse",
423
425
isExported: true,
424
-
properties: [
425
-
{ name: "logs", type: "LogEntry[]" },
426
-
],
426
+
properties: [{ name: "logs", type: "LogEntry[]" }],
427
427
});
428
428
429
429
sourceFile.addInterface({
···
438
438
{ name: "sliceUri", type: "string", hasQuestionToken: true },
439
439
{ name: "level", type: "string" },
440
440
{ name: "message", type: "string" },
441
-
{ name: "metadata", type: "Record<string, unknown>", hasQuestionToken: true },
441
+
{
442
+
name: "metadata",
443
+
type: "Record<string, unknown>",
444
+
hasQuestionToken: true,
445
+
},
442
446
],
443
447
});
444
448
···
522
526
isExported: true,
523
527
type: "{ [K in T]?: WhereCondition }",
524
528
});
525
-
526
529
527
530
// IndexedRecord fields that are always available for filtering
528
531
sourceFile.addTypeAlias({
···
707
710
sourceFile.addInterface({
708
711
name: "ListOAuthClientsResponse",
709
712
isExported: true,
710
-
properties: [
711
-
{ name: "clients", type: "OAuthClientDetails[]" },
712
-
],
713
+
properties: [{ name: "clients", type: "OAuthClientDetails[]" }],
713
714
});
714
715
715
716
sourceFile.addInterface({
···
1625
1626
}
1626
1627
}
1627
1628
1628
-
// Add codegen method to the social.slices.slice class
1629
+
// Add codegen method to the network.slices.slice class
1629
1630
if (
1630
1631
currentPath.length === 3 &&
1631
-
currentPath[0] === "social" &&
1632
+
currentPath[0] === "network" &&
1632
1633
currentPath[1] === "slices" &&
1633
1634
currentPath[2] === "slice"
1634
1635
) {
···
1638
1639
returnType: "Promise<CodegenXrpcResponse>",
1639
1640
isAsync: true,
1640
1641
statements: [
1641
-
`return await this.makeRequest<CodegenXrpcResponse>('social.slices.slice.codegen', 'POST', request);`,
1642
+
`return await this.makeRequest<CodegenXrpcResponse>('network.slices.slice.codegen', 'POST', request);`,
1642
1643
],
1643
1644
});
1644
1645
···
1648
1649
returnType: "Promise<SliceStatsOutput>",
1649
1650
isAsync: true,
1650
1651
statements: [
1651
-
`return await this.makeRequest<SliceStatsOutput>('social.slices.slice.stats', 'POST', params);`,
1652
+
`return await this.makeRequest<SliceStatsOutput>('network.slices.slice.stats', 'POST', params);`,
1652
1653
],
1653
1654
});
1654
1655
···
1656
1657
name: "getSliceRecords",
1657
1658
typeParameters: [{ name: "T", default: "Record<string, unknown>" }],
1658
1659
parameters: [
1659
-
{ name: "params", type: "Omit<SliceLevelRecordsParams<T>, 'slice'>" },
1660
+
{
1661
+
name: "params",
1662
+
type: "Omit<SliceLevelRecordsParams<T>, 'slice'>",
1663
+
},
1660
1664
],
1661
1665
returnType: "Promise<SliceRecordsOutput<T>>",
1662
1666
isAsync: true,
···
1672
1676
` orWhere: undefined, // Remove orWhere as it's now in where.$or`,
1673
1677
` slice: this.sliceUri`,
1674
1678
`};`,
1675
-
`return await this.makeRequest<SliceRecordsOutput<T>>('social.slices.slice.getSliceRecords', 'POST', requestParams);`,
1679
+
`return await this.makeRequest<SliceRecordsOutput<T>>('network.slices.slice.getSliceRecords', 'POST', requestParams);`,
1676
1680
],
1677
1681
});
1678
1682
···
1685
1689
isAsync: true,
1686
1690
statements: [
1687
1691
`const requestParams = { ...params, slice: this.sliceUri };`,
1688
-
`return await this.makeRequest<GetActorsResponse>('social.slices.slice.getActors', 'POST', requestParams);`,
1692
+
`return await this.makeRequest<GetActorsResponse>('network.slices.slice.getActors', 'POST', requestParams);`,
1689
1693
],
1690
1694
});
1691
1695
}
1692
1696
1693
-
// Add sync methods to the social.slices.slice class
1697
+
// Add sync methods to the network.slices.slice class
1694
1698
if (
1695
1699
currentPath.length === 3 &&
1696
-
currentPath[0] === "social" &&
1700
+
currentPath[0] === "network" &&
1697
1701
currentPath[1] === "slices" &&
1698
1702
currentPath[2] === "slice"
1699
1703
) {
···
1704
1708
isAsync: true,
1705
1709
statements: [
1706
1710
`const requestParams = { ...params, slice: this.sliceUri };`,
1707
-
`return await this.makeRequest<SyncJobResponse>('social.slices.slice.startSync', 'POST', requestParams);`,
1711
+
`return await this.makeRequest<SyncJobResponse>('network.slices.slice.startSync', 'POST', requestParams);`,
1708
1712
],
1709
1713
});
1710
1714
···
1714
1718
returnType: "Promise<JobStatus>",
1715
1719
isAsync: true,
1716
1720
statements: [
1717
-
`return await this.makeRequest<JobStatus>('social.slices.slice.getJobStatus', 'GET', params);`,
1721
+
`return await this.makeRequest<JobStatus>('network.slices.slice.getJobStatus', 'GET', params);`,
1718
1722
],
1719
1723
});
1720
1724
···
1724
1728
returnType: "Promise<GetJobHistoryResponse>",
1725
1729
isAsync: true,
1726
1730
statements: [
1727
-
`return await this.makeRequest<GetJobHistoryResponse>('social.slices.slice.getJobHistory', 'GET', params);`,
1731
+
`return await this.makeRequest<GetJobHistoryResponse>('network.slices.slice.getJobHistory', 'GET', params);`,
1728
1732
],
1729
1733
});
1730
1734
···
1734
1738
returnType: "Promise<GetJobLogsResponse>",
1735
1739
isAsync: true,
1736
1740
statements: [
1737
-
`return await this.makeRequest<GetJobLogsResponse>('social.slices.slice.getJobLogs', 'GET', params);`,
1741
+
`return await this.makeRequest<GetJobLogsResponse>('network.slices.slice.getJobLogs', 'GET', params);`,
1738
1742
],
1739
1743
});
1740
1744
···
1743
1747
returnType: "Promise<JetstreamStatusResponse>",
1744
1748
isAsync: true,
1745
1749
statements: [
1746
-
`return await this.makeRequest<JetstreamStatusResponse>('social.slices.slice.getJetstreamStatus', 'GET');`,
1750
+
`return await this.makeRequest<JetstreamStatusResponse>('network.slices.slice.getJetstreamStatus', 'GET');`,
1747
1751
],
1748
1752
});
1749
1753
···
1753
1757
returnType: "Promise<GetJetstreamLogsResponse>",
1754
1758
isAsync: true,
1755
1759
statements: [
1756
-
`return await this.makeRequest<GetJetstreamLogsResponse>('social.slices.slice.getJetstreamLogs', 'GET', params);`,
1760
+
`return await this.makeRequest<GetJetstreamLogsResponse>('network.slices.slice.getJetstreamLogs', 'GET', params);`,
1757
1761
],
1758
1762
});
1759
1763
···
1770
1774
isAsync: true,
1771
1775
statements: [
1772
1776
`const requestParams = { slice: this.sliceUri, ...params };`,
1773
-
`return await this.makeRequest<SyncUserCollectionsResult>('social.slices.slice.syncUserCollections', 'POST', requestParams);`,
1777
+
`return await this.makeRequest<SyncUserCollectionsResult>('network.slices.slice.syncUserCollections', 'POST', requestParams);`,
1774
1778
],
1775
1779
});
1776
1780
···
1782
1786
isAsync: true,
1783
1787
statements: [
1784
1788
`const requestParams = { ...params, sliceUri: this.sliceUri };`,
1785
-
`return await this.makeRequest<OAuthClientDetails>('social.slices.slice.createOAuthClient', 'POST', requestParams);`,
1789
+
`return await this.makeRequest<OAuthClientDetails>('network.slices.slice.createOAuthClient', 'POST', requestParams);`,
1786
1790
],
1787
1791
});
1788
1792
···
1792
1796
isAsync: true,
1793
1797
statements: [
1794
1798
`const requestParams = { slice: this.sliceUri };`,
1795
-
`return await this.makeRequest<ListOAuthClientsResponse>('social.slices.slice.getOAuthClients', 'GET', requestParams);`,
1799
+
`return await this.makeRequest<ListOAuthClientsResponse>('network.slices.slice.getOAuthClients', 'GET', requestParams);`,
1796
1800
],
1797
1801
});
1798
1802
···
1803
1807
isAsync: true,
1804
1808
statements: [
1805
1809
`const requestParams = { ...params, sliceUri: this.sliceUri };`,
1806
-
`return await this.makeRequest<OAuthClientDetails>('social.slices.slice.updateOAuthClient', 'POST', requestParams);`,
1810
+
`return await this.makeRequest<OAuthClientDetails>('network.slices.slice.updateOAuthClient', 'POST', requestParams);`,
1807
1811
],
1808
1812
});
1809
1813
···
1813
1817
returnType: "Promise<DeleteOAuthClientResponse>",
1814
1818
isAsync: true,
1815
1819
statements: [
1816
-
`return await this.makeRequest<DeleteOAuthClientResponse>('social.slices.slice.deleteOAuthClient', 'POST', { clientId });`,
1820
+
`return await this.makeRequest<DeleteOAuthClientResponse>('network.slices.slice.deleteOAuthClient', 'POST', { clientId });`,
1817
1821
],
1818
1822
});
1819
1823
}
···
1827
1831
isAsync: true,
1828
1832
statements: [
1829
1833
`const requestParams = { ...params, slice: this.sliceUri };`,
1830
-
`return await this.makeRequest<GetActorsResponse>('social.slices.slice.getActors', 'POST', requestParams);`,
1834
+
`return await this.makeRequest<GetActorsResponse>('network.slices.slice.getActors', 'POST', requestParams);`,
1831
1835
],
1832
1836
});
1833
1837
···
1835
1839
name: "getSliceRecords",
1836
1840
typeParameters: [{ name: "T", default: "Record<string, unknown>" }],
1837
1841
parameters: [
1838
-
{ name: "params", type: "Omit<SliceLevelRecordsParams<T>, 'slice'>" },
1842
+
{
1843
+
name: "params",
1844
+
type: "Omit<SliceLevelRecordsParams<T>, 'slice'>",
1845
+
},
1839
1846
],
1840
1847
returnType: "Promise<SliceRecordsOutput<T>>",
1841
1848
isAsync: true,
···
1851
1858
` orWhere: undefined, // Remove orWhere as it's now in where.$or`,
1852
1859
` slice: this.sliceUri`,
1853
1860
`};`,
1854
-
`return await this.makeRequest<SliceRecordsOutput<T>>('social.slices.slice.getSliceRecords', 'POST', requestParams);`,
1861
+
`return await this.makeRequest<SliceRecordsOutput<T>>('network.slices.slice.getSliceRecords', 'POST', requestParams);`,
1855
1862
],
1856
1863
});
1857
1864
+5
-5
api/scripts/prod_sync.sh
+5
-5
api/scripts/prod_sync.sh
···
10
10
echo "🔄 Testing Production Sync Endpoint..."
11
11
12
12
echo "🎯 Syncing slice collections with specific repos"
13
-
curl -s -X POST https://slices-api.fly.dev/xrpc/social.slices.slice.startSync \
13
+
curl -s -X POST https://slices-api.fly.dev/xrpc/network.slices.slice.startSync \
14
14
-H "Content-Type: application/json" \
15
15
-H "Authorization: Bearer $ACCESS_TOKEN" \
16
16
-d '{
17
-
"slice": "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q",
17
+
"slice": "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z",
18
18
"collections": [
19
-
"social.slices.slice",
20
-
"social.slices.lexicon",
21
-
"social.slices.actor.profile"
19
+
"network.slices.slice",
20
+
"network.slices.lexicon",
21
+
"network.slices.actor.profile"
22
22
],
23
23
"externalCollections": [
24
24
"app.bsky.actor.profile"
+5
-5
api/scripts/test_sync.sh
+5
-5
api/scripts/test_sync.sh
···
10
10
echo "🔄 Testing Sync Endpoint..."
11
11
12
12
echo "🎯 Syncing specific collections with specific repos"
13
-
curl -s -X POST http://localhost:3000/xrpc/social.slices.slice.startSync \
13
+
curl -s -X POST http://localhost:3000/xrpc/network.slices.slice.startSync \
14
14
-H "Content-Type: application/json" \
15
15
-H "Authorization: Bearer $TOKEN" \
16
16
-d '{
17
-
"slice": "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q",
17
+
"slice": "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z",
18
18
"collections": [
19
-
"social.slices.actor.profile",
20
-
"social.slices.slice",
21
-
"social.slices.lexicon"
19
+
"network.slices.actor.profile",
20
+
"network.slices.slice",
21
+
"network.slices.lexicon"
22
22
],
23
23
"externalCollections": [
24
24
"app.bsky.actor.profile"
+206
-127
api/src/database.rs
+206
-127
api/src/database.rs
···
1
-
use sqlx::PgPool;
2
1
use base64::{Engine as _, engine::general_purpose};
2
+
use sqlx::PgPool;
3
3
4
4
use crate::errors::DatabaseError;
5
-
use crate::models::{Actor, CollectionStats, IndexedRecord, Record, WhereCondition, WhereClause, SortField, OAuthClient};
5
+
use crate::models::{
6
+
Actor, CollectionStats, IndexedRecord, OAuthClient, Record, SortField, WhereClause,
7
+
WhereCondition,
8
+
};
6
9
use std::collections::HashMap;
7
-
8
10
9
11
// Helper function to build ORDER BY clause from sortBy array
10
12
fn build_order_by_clause(sort_by: Option<&Vec<SortField>>) -> String {
···
19
21
};
20
22
21
23
// Validate field name to prevent SQL injection
22
-
if field.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
23
-
if field == "indexed_at" || field == "uri" || field == "cid" || field == "did" || field == "collection" {
24
+
if field
25
+
.chars()
26
+
.all(|c| c.is_alphanumeric() || c == '_' || c == '.')
27
+
{
28
+
if field == "indexed_at"
29
+
|| field == "uri"
30
+
|| field == "cid"
31
+
|| field == "did"
32
+
|| field == "collection"
33
+
{
24
34
order_clauses.push(format!("{field} {direction}"));
25
35
} else {
26
36
// For JSON fields, handle nested paths and NULLs properly
···
43
53
}
44
54
if !order_clauses.is_empty() {
45
55
// Always add indexed_at as tie-breaker if not already included
46
-
let has_indexed_at = order_clauses.iter().any(|clause| clause.contains("indexed_at"));
56
+
let has_indexed_at = order_clauses
57
+
.iter()
58
+
.any(|clause| clause.contains("indexed_at"));
47
59
if !has_indexed_at {
48
60
order_clauses.push("indexed_at DESC".to_string());
49
61
}
···
56
68
}
57
69
}
58
70
59
-
60
-
fn generate_cursor(sort_value: &str, indexed_at: chrono::DateTime<chrono::Utc>, cid: &str) -> String {
71
+
fn generate_cursor(
72
+
sort_value: &str,
73
+
indexed_at: chrono::DateTime<chrono::Utc>,
74
+
cid: &str,
75
+
) -> String {
61
76
let cursor_content = format!("{}::{}::{}", sort_value, indexed_at.to_rfc3339(), cid);
62
77
general_purpose::URL_SAFE_NO_PAD.encode(cursor_content)
63
78
}
···
65
80
// Extract the primary sort field from sortBy array for cursor generation
66
81
fn get_primary_sort_field(sort_by: Option<&Vec<SortField>>) -> String {
67
82
match sort_by {
68
-
Some(sort_fields) if !sort_fields.is_empty() => {
69
-
sort_fields[0].field.clone()
70
-
}
83
+
Some(sort_fields) if !sort_fields.is_empty() => sort_fields[0].field.clone(),
71
84
_ => "indexed_at".to_string(),
72
85
}
73
86
}
···
81
94
"indexed_at" => record.indexed_at.to_rfc3339(),
82
95
field => {
83
96
// Extract field value from JSON
84
-
record.json.get(field)
97
+
record
98
+
.json
99
+
.get(field)
85
100
.and_then(|v| match v {
86
101
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
87
102
serde_json::Value::Number(n) => Some(n.to_string()),
···
103
118
) -> (Vec<String>, Vec<String>) {
104
119
let mut where_clauses = Vec::new();
105
120
let mut or_clauses = Vec::new();
106
-
121
+
107
122
if let Some(clause) = where_clause {
108
123
// Process regular AND conditions
109
124
for (field, condition) in &clause.conditions {
110
125
let field_clause = build_single_condition(field, condition, param_count);
111
126
where_clauses.push(field_clause);
112
127
}
113
-
128
+
114
129
// Process OR conditions
115
130
if let Some(or_conditions) = &clause.or_conditions {
116
131
for (field, condition) in or_conditions {
···
119
134
}
120
135
}
121
136
}
122
-
137
+
123
138
(where_clauses, or_clauses)
124
139
}
125
140
126
141
// Helper function to bind parameters from WhereClause
127
142
fn bind_where_parameters<'q>(
128
-
mut query_builder: sqlx::query::QueryAs<'q, sqlx::Postgres, Record, sqlx::postgres::PgArguments>,
129
-
where_clause: Option<&'q WhereClause>
143
+
mut query_builder: sqlx::query::QueryAs<
144
+
'q,
145
+
sqlx::Postgres,
146
+
Record,
147
+
sqlx::postgres::PgArguments,
148
+
>,
149
+
where_clause: Option<&'q WhereClause>,
130
150
) -> sqlx::query::QueryAs<'q, sqlx::Postgres, Record, sqlx::postgres::PgArguments> {
131
151
if let Some(clause) = where_clause {
132
152
// Bind AND condition parameters
133
153
for (_, condition) in &clause.conditions {
134
154
query_builder = bind_single_condition(query_builder, condition);
135
155
}
136
-
137
-
// Bind OR condition parameters
156
+
157
+
// Bind OR condition parameters
138
158
if let Some(or_conditions) = &clause.or_conditions {
139
159
for (_, condition) in or_conditions {
140
160
query_builder = bind_single_condition(query_builder, condition);
···
146
166
147
167
// Helper function to bind parameters for a single condition
148
168
fn bind_single_condition<'q>(
149
-
mut query_builder: sqlx::query::QueryAs<'q, sqlx::Postgres, Record, sqlx::postgres::PgArguments>,
150
-
condition: &'q WhereCondition
169
+
mut query_builder: sqlx::query::QueryAs<
170
+
'q,
171
+
sqlx::Postgres,
172
+
Record,
173
+
sqlx::postgres::PgArguments,
174
+
>,
175
+
condition: &'q WhereCondition,
151
176
) -> sqlx::query::QueryAs<'q, sqlx::Postgres, Record, sqlx::postgres::PgArguments> {
152
177
if let Some(eq_value) = &condition.eq {
153
178
if let Some(str_val) = eq_value.as_str() {
···
156
181
query_builder = query_builder.bind(eq_value);
157
182
}
158
183
}
159
-
184
+
160
185
if let Some(in_values) = &condition.in_values {
161
-
let str_values: Vec<String> = in_values.iter()
186
+
let str_values: Vec<String> = in_values
187
+
.iter()
162
188
.filter_map(|v| v.as_str().map(|s| s.to_string()))
163
189
.collect();
164
190
query_builder = query_builder.bind(str_values);
165
191
}
166
-
192
+
167
193
if let Some(contains_value) = &condition.contains {
168
194
query_builder = query_builder.bind(contains_value);
169
195
}
170
-
196
+
171
197
query_builder
172
198
}
173
199
174
200
// Helper function to build a single condition clause
175
-
fn build_single_condition(field: &str, condition: &WhereCondition, param_count: &mut usize) -> String {
201
+
fn build_single_condition(
202
+
field: &str,
203
+
condition: &WhereCondition,
204
+
param_count: &mut usize,
205
+
) -> String {
176
206
if let Some(_eq_value) = &condition.eq {
177
207
let clause = match field {
178
208
"did" | "collection" | "uri" | "cid" => {
···
309
339
310
340
// Build bulk INSERT with multiple VALUES
311
341
let mut query = String::from(
312
-
r#"INSERT INTO "record" ("uri", "cid", "did", "collection", "json", "indexed_at", "slice_uri") VALUES "#
342
+
r#"INSERT INTO "record" ("uri", "cid", "did", "collection", "json", "indexed_at", "slice_uri") VALUES "#,
313
343
);
314
344
315
345
// Add placeholders for each record
···
320
350
let base = i * 7 + 1; // 7 fields per record
321
351
query.push_str(&format!(
322
352
"(${}, ${}, ${}, ${}, ${}, ${}, ${})",
323
-
base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6
353
+
base,
354
+
base + 1,
355
+
base + 2,
356
+
base + 3,
357
+
base + 4,
358
+
base + 5,
359
+
base + 6
324
360
));
325
361
}
326
362
327
-
query.push_str(r#"
363
+
query.push_str(
364
+
r#"
328
365
ON CONFLICT ON CONSTRAINT record_pkey
329
366
DO UPDATE SET
330
367
"cid" = EXCLUDED."cid",
331
368
"json" = EXCLUDED."json",
332
369
"indexed_at" = EXCLUDED."indexed_at"
333
-
"#);
370
+
"#,
371
+
);
334
372
335
373
// Bind all parameters
336
374
let mut sqlx_query = sqlx::query(&query);
···
351
389
Ok(())
352
390
}
353
391
354
-
pub async fn get_existing_record_cids_for_slice(&self, did: &str, collection: &str, slice_uri: &str) -> Result<std::collections::HashMap<String, String>, DatabaseError> {
392
+
pub async fn get_existing_record_cids_for_slice(
393
+
&self,
394
+
did: &str,
395
+
collection: &str,
396
+
slice_uri: &str,
397
+
) -> Result<std::collections::HashMap<String, String>, DatabaseError> {
355
398
let records = sqlx::query!(
356
399
r#"SELECT "uri", "cid"
357
400
FROM "record"
···
392
435
Ok(indexed_record)
393
436
}
394
437
395
-
396
-
pub async fn get_lexicons_by_slice(&self, slice_uri: &str) -> Result<Vec<serde_json::Value>, DatabaseError> {
438
+
pub async fn get_lexicons_by_slice(
439
+
&self,
440
+
slice_uri: &str,
441
+
) -> Result<Vec<serde_json::Value>, DatabaseError> {
397
442
let records = sqlx::query_as::<_, Record>(
398
443
r#"SELECT "uri", "cid", "did", "collection", "json", "indexed_at", "slice_uri"
399
444
FROM "record"
400
-
WHERE "collection" = 'social.slices.lexicon'
445
+
WHERE "collection" = 'network.slices.lexicon'
401
446
AND "json"->>'slice' = $1
402
447
ORDER BY "indexed_at" DESC"#,
403
448
)
···
421
466
422
467
Ok(lexicon_definitions)
423
468
}
424
-
425
-
426
-
427
469
428
470
pub async fn update_record(&self, record: &Record) -> Result<(), DatabaseError> {
429
471
let result = sqlx::query!(
···
440
482
.await?;
441
483
442
484
if result.rows_affected() == 0 {
443
-
return Err(DatabaseError::RecordNotFound { uri: record.uri.clone() });
485
+
return Err(DatabaseError::RecordNotFound {
486
+
uri: record.uri.clone(),
487
+
});
444
488
}
445
489
446
490
Ok(())
447
491
}
448
-
449
492
450
493
pub async fn batch_insert_actors(&self, actors: &[Actor]) -> Result<(), DatabaseError> {
451
494
if actors.is_empty() {
···
456
499
457
500
// Process actors in chunks to avoid hitting parameter limits
458
501
const CHUNK_SIZE: usize = 1000;
459
-
502
+
460
503
for chunk in actors.chunks(CHUNK_SIZE) {
461
504
for actor in chunk {
462
505
sqlx::query!(
···
480
523
Ok(())
481
524
}
482
525
483
-
pub async fn get_slice_collection_stats(&self, slice_uri: &str) -> Result<Vec<CollectionStats>, DatabaseError> {
526
+
pub async fn get_slice_collection_stats(
527
+
&self,
528
+
slice_uri: &str,
529
+
) -> Result<Vec<CollectionStats>, DatabaseError> {
484
530
let stats = sqlx::query!(
485
531
r#"
486
532
WITH slice_collections AS (
487
533
SELECT DISTINCT
488
534
json->>'nsid' as collection_nsid
489
535
FROM record
490
-
WHERE collection = 'social.slices.lexicon'
536
+
WHERE collection = 'network.slices.lexicon'
491
537
AND json->>'slice' = $1
492
538
AND json->>'nsid' IS NOT NULL
493
539
AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'
···
507
553
.fetch_all(&self.pool)
508
554
.await?;
509
555
510
-
Ok(stats.into_iter().map(|row| CollectionStats {
511
-
collection: row.collection,
512
-
record_count: row.record_count.unwrap_or(0),
513
-
unique_actors: row.unique_actors.unwrap_or(0),
514
-
}).collect())
556
+
Ok(stats
557
+
.into_iter()
558
+
.map(|row| CollectionStats {
559
+
collection: row.collection,
560
+
record_count: row.record_count.unwrap_or(0),
561
+
unique_actors: row.unique_actors.unwrap_or(0),
562
+
})
563
+
.collect())
515
564
}
516
565
517
-
pub async fn get_slice_collections_list(&self, slice_uri: &str) -> Result<Vec<String>, DatabaseError> {
566
+
pub async fn get_slice_collections_list(
567
+
&self,
568
+
slice_uri: &str,
569
+
) -> Result<Vec<String>, DatabaseError> {
518
570
let rows = sqlx::query!(
519
571
r#"
520
572
SELECT DISTINCT json->>'nsid' as collection_nsid
521
573
FROM record
522
-
WHERE collection = 'social.slices.lexicon'
574
+
WHERE collection = 'network.slices.lexicon'
523
575
AND json->>'slice' = $1
524
576
AND json->>'nsid' IS NOT NULL
525
577
AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'
···
530
582
.fetch_all(&self.pool)
531
583
.await?;
532
584
533
-
Ok(rows.into_iter()
585
+
Ok(rows
586
+
.into_iter()
534
587
.filter_map(|row| row.collection_nsid)
535
588
.collect())
536
589
}
···
542
595
SELECT DISTINCT
543
596
json->>'nsid' as collection_nsid
544
597
FROM record
545
-
WHERE collection = 'social.slices.lexicon'
598
+
WHERE collection = 'network.slices.lexicon'
546
599
AND json->>'slice' = $1
547
600
AND json->>'nsid' IS NOT NULL
548
601
AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'
···
580
633
slice_uri: &str,
581
634
limit: Option<i32>,
582
635
cursor: Option<&str>,
583
-
where_conditions: Option<&HashMap<String, WhereCondition>>
636
+
where_conditions: Option<&HashMap<String, WhereCondition>>,
584
637
) -> Result<(Vec<Actor>, Option<String>), DatabaseError> {
585
638
let limit = limit.unwrap_or(50).min(100); // Cap at 100
586
-
639
+
587
640
// Handle where conditions with specific cases
588
641
let records = if let Some(conditions) = where_conditions {
589
642
// Check for handle contains filter
···
662
715
}
663
716
} else {
664
717
// Default case with basic filtering
665
-
self.query_actors_with_cursor(slice_uri, cursor, limit).await?
718
+
self.query_actors_with_cursor(slice_uri, cursor, limit)
719
+
.await?
666
720
}
667
721
} else if let Some(did_condition) = conditions.get("did") {
668
722
if let Some(in_values) = &did_condition.in_values {
669
-
let string_values: Vec<String> = in_values.iter()
723
+
let string_values: Vec<String> = in_values
724
+
.iter()
670
725
.filter_map(|v| v.as_str())
671
726
.map(|s| s.to_string())
672
727
.collect();
673
-
728
+
674
729
sqlx::query_as!(
675
730
Actor,
676
731
r#"
···
724
779
}
725
780
} else {
726
781
// Default case with basic filtering
727
-
self.query_actors_with_cursor(slice_uri, cursor, limit).await?
782
+
self.query_actors_with_cursor(slice_uri, cursor, limit)
783
+
.await?
728
784
}
729
785
} else {
730
786
// Default case with basic filtering
731
-
self.query_actors_with_cursor(slice_uri, cursor, limit).await?
787
+
self.query_actors_with_cursor(slice_uri, cursor, limit)
788
+
.await?
732
789
}
733
790
} else {
734
791
// No where conditions, just basic slice + cursor filtering
735
-
self.query_actors_with_cursor(slice_uri, cursor, limit).await?
792
+
self.query_actors_with_cursor(slice_uri, cursor, limit)
793
+
.await?
736
794
};
737
-
795
+
738
796
// Generate cursor from the last record if there are any records
739
797
let cursor = if records.is_empty() {
740
798
None
···
744
802
745
803
Ok((records, cursor))
746
804
}
747
-
748
-
async fn query_actors_with_cursor(&self, slice_uri: &str, cursor: Option<&str>, limit: i32) -> Result<Vec<Actor>, DatabaseError> {
805
+
806
+
async fn query_actors_with_cursor(
807
+
&self,
808
+
slice_uri: &str,
809
+
cursor: Option<&str>,
810
+
limit: i32,
811
+
) -> Result<Vec<Actor>, DatabaseError> {
749
812
match cursor {
750
-
Some(cursor_did) => {
751
-
sqlx::query_as!(
752
-
Actor,
753
-
r#"
813
+
Some(cursor_did) => sqlx::query_as!(
814
+
Actor,
815
+
r#"
754
816
SELECT did, handle, slice_uri, indexed_at
755
817
FROM actor
756
818
WHERE slice_uri = $1 AND did > $2
757
819
ORDER BY did ASC
758
820
LIMIT $3
759
821
"#,
760
-
slice_uri,
761
-
cursor_did,
762
-
limit as i64
763
-
)
764
-
.fetch_all(&self.pool)
765
-
.await
766
-
.map_err(DatabaseError::from)
767
-
},
768
-
None => {
769
-
sqlx::query_as!(
770
-
Actor,
771
-
r#"
822
+
slice_uri,
823
+
cursor_did,
824
+
limit as i64
825
+
)
826
+
.fetch_all(&self.pool)
827
+
.await
828
+
.map_err(DatabaseError::from),
829
+
None => sqlx::query_as!(
830
+
Actor,
831
+
r#"
772
832
SELECT did, handle, slice_uri, indexed_at
773
833
FROM actor
774
834
WHERE slice_uri = $1
775
835
ORDER BY did ASC
776
836
LIMIT $2
777
837
"#,
778
-
slice_uri,
779
-
limit as i64
780
-
)
781
-
.fetch_all(&self.pool)
782
-
.await
783
-
.map_err(DatabaseError::from)
784
-
}
838
+
slice_uri,
839
+
limit as i64
840
+
)
841
+
.fetch_all(&self.pool)
842
+
.await
843
+
.map_err(DatabaseError::from),
785
844
}
786
845
}
787
846
···
790
849
r#"
791
850
SELECT COUNT(*) as count
792
851
FROM record
793
-
WHERE collection = 'social.slices.lexicon'
852
+
WHERE collection = 'network.slices.lexicon'
794
853
AND json->>'slice' = $1
795
854
AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'
796
855
"#,
···
802
861
Ok(count.count.unwrap_or(0))
803
862
}
804
863
805
-
806
864
pub async fn get_slice_collections_records(
807
865
&self,
808
866
slice_uri: &str,
809
867
limit: Option<i32>,
810
868
cursor: Option<&str>,
811
869
sort_by: Option<&Vec<SortField>>,
812
-
where_clause: Option<&WhereClause>
870
+
where_clause: Option<&WhereClause>,
813
871
) -> Result<(Vec<Record>, Option<String>), DatabaseError> {
814
872
let limit = limit.unwrap_or(50).min(100); // Cap at 100
815
873
let order_by = build_order_by_clause(sort_by);
···
818
876
let mut where_clauses = Vec::new();
819
877
let mut param_count = 1;
820
878
821
-
// Always filter by slice_uri, except for social.slices.lexicon which uses json->>'slice'
822
-
let is_lexicon = where_clause.as_ref()
879
+
// Always filter by slice_uri, except for network.slices.lexicon which uses json->>'slice'
880
+
let is_lexicon = where_clause
881
+
.as_ref()
823
882
.and_then(|wc| wc.conditions.get("collection"))
824
883
.and_then(|c| c.eq.as_ref())
825
884
.and_then(|v| v.as_str())
826
-
.map_or(false, |s| s == "social.slices.lexicon");
827
-
828
-
if is_lexicon
829
-
{
885
+
.map_or(false, |s| s == "network.slices.lexicon");
886
+
887
+
if is_lexicon {
830
888
where_clauses.push(format!("json->>'slice' = ${}", param_count));
831
889
} else {
832
890
where_clauses.push(format!("slice_uri = ${}", param_count));
···
840
898
}
841
899
842
900
// Use helper function to build where conditions
843
-
let (and_conditions, or_conditions) = build_where_conditions(where_clause, &mut param_count);
901
+
let (and_conditions, or_conditions) =
902
+
build_where_conditions(where_clause, &mut param_count);
844
903
where_clauses.extend(and_conditions);
845
-
904
+
846
905
// Add OR conditions with proper parentheses if present
847
906
if !or_conditions.is_empty() {
848
907
let or_clause = format!("({})", or_conditions.join(" OR "));
849
908
where_clauses.push(or_clause);
850
909
}
851
-
852
910
853
911
// Build the final query
854
912
let where_sql = where_clauses.join(" AND ");
···
869
927
870
928
// Bind cursor if present
871
929
if let Some(cursor_time) = cursor {
872
-
let cursor_dt = cursor_time.parse::<chrono::DateTime<chrono::Utc>>()
930
+
let cursor_dt = cursor_time
931
+
.parse::<chrono::DateTime<chrono::Utc>>()
873
932
.unwrap_or_else(|_| chrono::Utc::now());
874
933
query_builder = query_builder.bind(cursor_dt);
875
934
}
···
887
946
let cursor = if records.is_empty() {
888
947
None
889
948
} else {
890
-
records.last().map(|record| generate_cursor_from_record(record, sort_by))
949
+
records
950
+
.last()
951
+
.map(|record| generate_cursor_from_record(record, sort_by))
891
952
};
892
953
893
954
Ok((records, cursor))
···
896
957
pub async fn count_slice_collections_records(
897
958
&self,
898
959
slice_uri: &str,
899
-
where_clause: Option<&WhereClause>
960
+
where_clause: Option<&WhereClause>,
900
961
) -> Result<i64, DatabaseError> {
901
962
// Build WHERE clause dynamically
902
963
let mut where_clauses = Vec::new();
903
964
let mut param_count = 1;
904
965
905
-
// Always filter by slice_uri, except for social.slices.lexicon which uses json->>'slice'
906
-
let is_lexicon = where_clause.as_ref()
966
+
// Always filter by slice_uri, except for network.slices.lexicon which uses json->>'slice'
967
+
let is_lexicon = where_clause
968
+
.as_ref()
907
969
.and_then(|wc| wc.conditions.get("collection"))
908
970
.and_then(|c| c.eq.as_ref())
909
971
.and_then(|v| v.as_str())
910
-
.map_or(false, |s| s == "social.slices.lexicon");
911
-
912
-
if is_lexicon
913
-
{
972
+
.map_or(false, |s| s == "network.slices.lexicon");
973
+
974
+
if is_lexicon {
914
975
where_clauses.push(format!("json->>'slice' = ${}", param_count));
915
976
} else {
916
977
where_clauses.push(format!("slice_uri = ${}", param_count));
···
918
979
param_count += 1;
919
980
920
981
// Use helper function to build where conditions
921
-
let (and_conditions, or_conditions) = build_where_conditions(where_clause, &mut param_count);
982
+
let (and_conditions, or_conditions) =
983
+
build_where_conditions(where_clause, &mut param_count);
922
984
where_clauses.extend(and_conditions);
923
-
985
+
924
986
// Add OR conditions with proper parentheses if present
925
987
if !or_conditions.is_empty() {
926
988
let or_clause = format!("({})", or_conditions.join(" OR "));
927
989
where_clauses.push(or_clause);
928
990
}
929
-
930
991
931
992
// Build the final query
932
993
let where_sql = if where_clauses.is_empty() {
···
937
998
938
999
let query = format!("SELECT COUNT(*) as count FROM record{}", where_sql);
939
1000
940
-
// Execute query with parameters
1001
+
// Execute query with parameters
941
1002
let mut query_builder = sqlx::query_scalar::<_, i64>(&query);
942
1003
query_builder = query_builder.bind(slice_uri);
943
1004
···
953
1014
}
954
1015
}
955
1016
if let Some(in_values) = &condition.in_values {
956
-
let str_values: Vec<String> = in_values.iter()
1017
+
let str_values: Vec<String> = in_values
1018
+
.iter()
957
1019
.filter_map(|v| v.as_str().map(|s| s.to_string()))
958
1020
.collect();
959
1021
query_builder = query_builder.bind(str_values);
···
962
1024
query_builder = query_builder.bind(contains_value);
963
1025
}
964
1026
}
965
-
1027
+
966
1028
// Bind OR condition parameters
967
1029
if let Some(or_conditions) = &clause.or_conditions {
968
1030
for (_, condition) in or_conditions {
···
974
1036
}
975
1037
}
976
1038
if let Some(in_values) = &condition.in_values {
977
-
let str_values: Vec<String> = in_values.iter()
1039
+
let str_values: Vec<String> = in_values
1040
+
.iter()
978
1041
.filter_map(|v| v.as_str().map(|s| s.to_string()))
979
1042
.collect();
980
1043
query_builder = query_builder.bind(str_values);
···
990
1053
Ok(count)
991
1054
}
992
1055
993
-
pub async fn delete_record_by_uri(&self, uri: &str, slice_uri: Option<&str>) -> Result<u64, DatabaseError> {
1056
+
pub async fn delete_record_by_uri(
1057
+
&self,
1058
+
uri: &str,
1059
+
slice_uri: Option<&str>,
1060
+
) -> Result<u64, DatabaseError> {
994
1061
let result = if let Some(slice_uri) = slice_uri {
995
1062
sqlx::query("DELETE FROM record WHERE uri = $1 AND slice_uri = $2")
996
1063
.bind(uri)
···
1007
1074
Ok(result.rows_affected())
1008
1075
}
1009
1076
1010
-
1011
1077
pub async fn upsert_record(&self, record: &Record) -> Result<bool, DatabaseError> {
1012
1078
// Returns true if inserted, false if updated
1013
-
let result = sqlx::query_scalar::<_, bool>(r#"
1079
+
let result = sqlx::query_scalar::<_, bool>(
1080
+
r#"
1014
1081
INSERT INTO record (uri, cid, did, collection, json, indexed_at, slice_uri)
1015
1082
VALUES ($1, $2, $3, $4, $5, $6, $7)
1016
1083
ON CONFLICT ON CONSTRAINT record_pkey DO UPDATE
···
1018
1085
json = EXCLUDED.json,
1019
1086
indexed_at = EXCLUDED.indexed_at
1020
1087
RETURNING (xmax = 0)
1021
-
"#)
1088
+
"#,
1089
+
)
1022
1090
.bind(&record.uri)
1023
1091
.bind(&record.cid)
1024
1092
.bind(&record.did)
···
1032
1100
}
1033
1101
1034
1102
pub async fn get_all_slices(&self) -> Result<Vec<String>, DatabaseError> {
1035
-
let rows: Vec<(String,)> = sqlx::query_as(r#"
1103
+
let rows: Vec<(String,)> = sqlx::query_as(
1104
+
r#"
1036
1105
SELECT DISTINCT json->>'slice' as slice_uri
1037
1106
FROM record
1038
-
WHERE collection = 'social.slices.lexicon'
1107
+
WHERE collection = 'network.slices.lexicon'
1039
1108
AND json->>'slice' IS NOT NULL
1040
-
"#)
1109
+
"#,
1110
+
)
1041
1111
.fetch_all(&self.pool)
1042
1112
.await?;
1043
1113
1044
1114
Ok(rows.into_iter().map(|(uri,)| uri).collect())
1045
1115
}
1046
-
1047
1116
1048
1117
pub async fn get_all_actors(&self) -> Result<Vec<(String, String)>, DatabaseError> {
1049
1118
let rows = sqlx::query!(
···
1055
1124
.fetch_all(&self.pool)
1056
1125
.await?;
1057
1126
1058
-
Ok(rows.into_iter().map(|row| (row.did, row.slice_uri)).collect())
1127
+
Ok(rows
1128
+
.into_iter()
1129
+
.map(|row| (row.did, row.slice_uri))
1130
+
.collect())
1059
1131
}
1060
1132
1061
1133
pub async fn get_slice_domain(&self, slice_uri: &str) -> Result<Option<String>, DatabaseError> {
···
1063
1135
r#"
1064
1136
SELECT json->>'domain' as domain
1065
1137
FROM record
1066
-
WHERE collection = 'social.slices.slice'
1138
+
WHERE collection = 'network.slices.slice'
1067
1139
AND uri = $1
1068
1140
"#,
1069
1141
slice_uri
···
1099
1171
Ok(client)
1100
1172
}
1101
1173
1102
-
pub async fn get_oauth_clients_for_slice(&self, slice_uri: &str) -> Result<Vec<OAuthClient>, DatabaseError> {
1174
+
pub async fn get_oauth_clients_for_slice(
1175
+
&self,
1176
+
slice_uri: &str,
1177
+
) -> Result<Vec<OAuthClient>, DatabaseError> {
1103
1178
let clients = sqlx::query_as!(
1104
1179
OAuthClient,
1105
1180
r#"
···
1116
1191
Ok(clients)
1117
1192
}
1118
1193
1119
-
pub async fn get_oauth_client_by_id(&self, client_id: &str) -> Result<Option<OAuthClient>, DatabaseError> {
1194
+
pub async fn get_oauth_client_by_id(
1195
+
&self,
1196
+
client_id: &str,
1197
+
) -> Result<Option<OAuthClient>, DatabaseError> {
1120
1198
let client = sqlx::query_as!(
1121
1199
OAuthClient,
1122
1200
r#"
···
1144
1222
.await?;
1145
1223
1146
1224
if result.rows_affected() == 0 {
1147
-
return Err(DatabaseError::RecordNotFound { uri: client_id.to_string() });
1225
+
return Err(DatabaseError::RecordNotFound {
1226
+
uri: client_id.to_string(),
1227
+
});
1148
1228
}
1149
1229
1150
1230
Ok(())
1151
1231
}
1152
-
1153
1232
}
+42
-29
api/src/handler_sync_user_collections.rs
+42
-29
api/src/handler_sync_user_collections.rs
···
6
6
use serde::Deserialize;
7
7
use tracing::{info, warn};
8
8
9
+
use crate::AppState;
9
10
use crate::auth::{extract_bearer_token, verify_oauth_token};
10
11
use crate::sync::{SyncService, SyncUserCollectionsResult};
11
-
use crate::AppState;
12
12
13
13
#[derive(Deserialize)]
14
14
#[serde(rename_all = "camelCase")]
···
22
22
30 // 30 second default timeout for login scenarios
23
23
}
24
24
25
-
/// Handler for social.slices.slice.syncUserCollections
25
+
/// Handler for network.slices.slice.syncUserCollections
26
26
/// Synchronously syncs external collections for the authenticated user with timeout protection
27
27
/// Automatically discovers external collections based on the slice's domain configuration
28
28
pub async fn sync_user_collections(
···
32
32
) -> Result<Json<SyncUserCollectionsResult>, (StatusCode, Json<serde_json::Value>)> {
33
33
// Extract and verify OAuth token
34
34
let token = extract_bearer_token(&headers).map_err(|e| {
35
-
(StatusCode::UNAUTHORIZED, Json(serde_json::json!({
36
-
"error": "AuthenticationRequired",
37
-
"message": format!("Bearer token required: {}", e)
38
-
})))
35
+
(
36
+
StatusCode::UNAUTHORIZED,
37
+
Json(serde_json::json!({
38
+
"error": "AuthenticationRequired",
39
+
"message": format!("Bearer token required: {}", e)
40
+
})),
41
+
)
39
42
})?;
40
43
41
-
let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await
44
+
let user_info = verify_oauth_token(&token, &state.config.auth_base_url)
45
+
.await
42
46
.map_err(|e| {
43
-
(StatusCode::UNAUTHORIZED, Json(serde_json::json!({
44
-
"error": "InvalidToken",
45
-
"message": format!("Token verification failed: {}", e)
46
-
})))
47
+
(
48
+
StatusCode::UNAUTHORIZED,
49
+
Json(serde_json::json!({
50
+
"error": "InvalidToken",
51
+
"message": format!("Token verification failed: {}", e)
52
+
})),
53
+
)
47
54
})?;
48
55
49
56
let user_did = user_info.did.unwrap_or(user_info.sub);
50
-
57
+
51
58
info!(
52
59
"🔄 Starting user collections sync for {} on slice {} (timeout: {}s)",
53
60
user_did, request.slice, request.timeout_seconds
···
55
62
56
63
// Validate timeout (max 5 minutes for sync operations)
57
64
if request.timeout_seconds > 300 {
58
-
return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({
59
-
"error": "InvalidTimeout",
60
-
"message": "Maximum timeout is 300 seconds (5 minutes)"
61
-
}))));
65
+
return Err((
66
+
StatusCode::BAD_REQUEST,
67
+
Json(serde_json::json!({
68
+
"error": "InvalidTimeout",
69
+
"message": "Maximum timeout is 300 seconds (5 minutes)"
70
+
})),
71
+
));
62
72
}
63
73
64
74
// Create sync service
65
-
let sync_service = SyncService::new(state.database.clone(), state.config.relay_endpoint.clone());
66
-
75
+
let sync_service =
76
+
SyncService::new(state.database.clone(), state.config.relay_endpoint.clone());
77
+
67
78
// Perform timeout-protected sync with auto-discovered external collections
68
-
match sync_service.sync_user_collections(
69
-
&user_did,
70
-
&request.slice,
71
-
request.timeout_seconds,
72
-
).await {
79
+
match sync_service
80
+
.sync_user_collections(&user_did, &request.slice, request.timeout_seconds)
81
+
.await
82
+
{
73
83
Ok(result) => {
74
84
if result.timed_out {
75
85
info!(
···
83
93
);
84
94
}
85
95
Ok(Json(result))
86
-
},
96
+
}
87
97
Err(e) => {
88
98
warn!("❌ Sync failed for user {}: {}", user_did, e);
89
-
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
90
-
"error": "SyncFailed",
91
-
"message": format!("Sync operation failed: {}", e)
92
-
}))))
99
+
Err((
100
+
StatusCode::INTERNAL_SERVER_ERROR,
101
+
Json(serde_json::json!({
102
+
"error": "SyncFailed",
103
+
"message": format!("Sync operation failed: {}", e)
104
+
})),
105
+
))
93
106
}
94
107
}
95
-
}
108
+
}
+365
-232
api/src/handler_xrpc_dynamic.rs
+365
-232
api/src/handler_xrpc_dynamic.rs
···
1
+
use atproto_client::com::atproto::repo::{
2
+
CreateRecordRequest, CreateRecordResponse, DeleteRecordRequest, PutRecordRequest,
3
+
PutRecordResponse, create_record, delete_record, put_record,
4
+
};
1
5
use axum::{
2
6
extract::{Path, Query, State},
3
7
http::{HeaderMap, StatusCode},
4
8
response::Json,
5
9
};
6
-
use serde::Deserialize;
7
10
use chrono::Utc;
8
-
use atproto_client::com::atproto::repo::{CreateRecordRequest, PutRecordRequest, DeleteRecordRequest, create_record, put_record, delete_record, CreateRecordResponse, PutRecordResponse};
11
+
use serde::Deserialize;
9
12
10
-
use crate::auth::{extract_bearer_token, verify_oauth_token, get_atproto_auth_for_user};
13
+
use crate::AppState;
14
+
use crate::auth::{extract_bearer_token, get_atproto_auth_for_user, verify_oauth_token};
11
15
use crate::lexicon::LexiconValidator;
12
-
use crate::models::{Record, SliceRecordsParams, SliceRecordsOutput, IndexedRecord, WhereCondition, SortField};
16
+
use crate::models::{
17
+
IndexedRecord, Record, SliceRecordsOutput, SliceRecordsParams, SortField, WhereCondition,
18
+
};
13
19
use std::collections::HashMap;
14
-
use crate::AppState;
15
20
16
21
// Helper function to convert StatusCode errors to JSON error responses
17
22
fn status_to_error_response(status: StatusCode) -> (StatusCode, Json<serde_json::Value>) {
···
24
29
_ => "Request failed",
25
30
};
26
31
27
-
(status, Json(serde_json::json!({
28
-
"error": status.as_str(),
29
-
"message": message
30
-
})))
32
+
(
33
+
status,
34
+
Json(serde_json::json!({
35
+
"error": status.as_str(),
36
+
"message": message
37
+
})),
38
+
)
31
39
}
32
-
33
-
34
40
35
41
#[derive(Deserialize)]
36
42
pub struct GetRecordParams {
···
66
72
headers: HeaderMap,
67
73
Json(body): Json<serde_json::Value>,
68
74
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
69
-
70
75
// Handle dynamic collection methods (e.g., social.grain.gallery.createRecord)
71
76
if method.ends_with(".getRecords") {
72
77
let collection = method.trim_end_matches(".getRecords").to_string();
···
95
100
params: serde_json::Value,
96
101
) -> Result<Json<serde_json::Value>, StatusCode> {
97
102
// Parse parameters into SliceRecordsParams format
98
-
let slice = params.get("slice")
103
+
let slice = params
104
+
.get("slice")
99
105
.and_then(|v| v.as_str())
100
106
.ok_or(StatusCode::BAD_REQUEST)?
101
107
.to_string();
···
108
114
}
109
115
});
110
116
111
-
let cursor = params.get("cursor")
117
+
let cursor = params
118
+
.get("cursor")
112
119
.and_then(|v| v.as_str())
113
120
.map(|s| s.to_string());
114
121
115
122
// Parse sortBy from params - convert legacy sort string to new array format if present
116
-
let sort_by = params.get("sort")
117
-
.and_then(|v| v.as_str())
118
-
.map(|sort_str| {
119
-
// Convert legacy "field:direction" format to new array format
120
-
let mut sort_fields = Vec::new();
121
-
for sort_item in sort_str.split(',') {
122
-
let parts: Vec<&str> = sort_item.trim().split(':').collect();
123
-
if parts.len() == 2 {
124
-
sort_fields.push(SortField {
125
-
field: parts[0].trim().to_string(),
126
-
direction: parts[1].trim().to_string(),
127
-
});
128
-
} else if parts.len() == 1 && !parts[0].is_empty() {
129
-
// Default to ascending if no direction specified
130
-
sort_fields.push(SortField {
131
-
field: parts[0].trim().to_string(),
132
-
direction: "asc".to_string(),
133
-
});
134
-
}
123
+
let sort_by = params.get("sort").and_then(|v| v.as_str()).map(|sort_str| {
124
+
// Convert legacy "field:direction" format to new array format
125
+
let mut sort_fields = Vec::new();
126
+
for sort_item in sort_str.split(',') {
127
+
let parts: Vec<&str> = sort_item.trim().split(':').collect();
128
+
if parts.len() == 2 {
129
+
sort_fields.push(SortField {
130
+
field: parts[0].trim().to_string(),
131
+
direction: parts[1].trim().to_string(),
132
+
});
133
+
} else if parts.len() == 1 && !parts[0].is_empty() {
134
+
// Default to ascending if no direction specified
135
+
sort_fields.push(SortField {
136
+
field: parts[0].trim().to_string(),
137
+
direction: "asc".to_string(),
138
+
});
135
139
}
136
-
sort_fields
137
-
});
140
+
}
141
+
sort_fields
142
+
});
138
143
139
144
// Parse where conditions from query params if present
140
145
let mut where_conditions = HashMap::new();
141
146
142
147
// Handle legacy author/authors params by converting to where clause
143
148
if let Some(author_str) = params.get("author").and_then(|v| v.as_str()) {
144
-
where_conditions.insert("did".to_string(), WhereCondition {
145
-
eq: Some(serde_json::Value::String(author_str.to_string())),
146
-
in_values: None,
147
-
contains: None,
148
-
});
149
+
where_conditions.insert(
150
+
"did".to_string(),
151
+
WhereCondition {
152
+
eq: Some(serde_json::Value::String(author_str.to_string())),
153
+
in_values: None,
154
+
contains: None,
155
+
},
156
+
);
149
157
} else if let Some(authors_str) = params.get("authors").and_then(|v| v.as_str()) {
150
158
let authors: Vec<serde_json::Value> = authors_str
151
159
.split(',')
152
160
.map(|s| serde_json::Value::String(s.trim().to_string()))
153
161
.collect();
154
-
where_conditions.insert("did".to_string(), WhereCondition {
155
-
eq: None,
156
-
in_values: Some(authors),
157
-
contains: None,
158
-
});
162
+
where_conditions.insert(
163
+
"did".to_string(),
164
+
WhereCondition {
165
+
eq: None,
166
+
in_values: Some(authors),
167
+
contains: None,
168
+
},
169
+
);
159
170
}
160
171
161
172
// Handle legacy query param by converting to where clause with contains
162
173
if let Some(query_str) = params.get("query").and_then(|v| v.as_str()) {
163
-
let field = params.get("field")
174
+
let field = params
175
+
.get("field")
164
176
.and_then(|v| v.as_str())
165
177
.unwrap_or("text"); // Default to text field for search
166
-
where_conditions.insert(field.to_string(), WhereCondition {
167
-
eq: None,
168
-
in_values: None,
169
-
contains: Some(query_str.to_string()),
170
-
});
178
+
where_conditions.insert(
179
+
field.to_string(),
180
+
WhereCondition {
181
+
eq: None,
182
+
in_values: None,
183
+
contains: Some(query_str.to_string()),
184
+
},
185
+
);
171
186
}
172
187
173
188
let where_clause = if where_conditions.is_empty() {
···
188
203
};
189
204
190
205
// First verify the collection belongs to this slice
191
-
let slice_collections = state.database.get_slice_collections_list(&records_params.slice).await
206
+
let slice_collections = state
207
+
.database
208
+
.get_slice_collections_list(&records_params.slice)
209
+
.await
192
210
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
193
211
194
-
// Special handling: social.slices.lexicon is always allowed as it defines the schema
195
-
if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) {
212
+
// Special handling: network.slices.lexicon is always allowed as it defines the schema
213
+
if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) {
196
214
return Err(StatusCode::NOT_FOUND);
197
215
}
198
216
199
217
// Use the unified database method
200
-
match state.database.get_slice_collections_records(
201
-
&records_params.slice,
202
-
records_params.limit,
203
-
records_params.cursor.as_deref(),
204
-
sort_by.as_ref(),
205
-
records_params.where_clause.as_ref(),
206
-
).await {
218
+
match state
219
+
.database
220
+
.get_slice_collections_records(
221
+
&records_params.slice,
222
+
records_params.limit,
223
+
records_params.cursor.as_deref(),
224
+
sort_by.as_ref(),
225
+
records_params.where_clause.as_ref(),
226
+
)
227
+
.await
228
+
{
207
229
Ok((mut records, cursor)) => {
208
230
// Filter records to only include the specific collection
209
231
records.retain(|record| record.collection == collection);
210
232
211
-
let indexed_records: Vec<IndexedRecord> = records.into_iter().map(|record| IndexedRecord {
212
-
uri: record.uri,
213
-
cid: record.cid,
214
-
did: record.did,
215
-
collection: record.collection,
216
-
value: record.json,
217
-
indexed_at: record.indexed_at.to_rfc3339(),
218
-
}).collect();
233
+
let indexed_records: Vec<IndexedRecord> = records
234
+
.into_iter()
235
+
.map(|record| IndexedRecord {
236
+
uri: record.uri,
237
+
cid: record.cid,
238
+
did: record.did,
239
+
collection: record.collection,
240
+
value: record.json,
241
+
indexed_at: record.indexed_at.to_rfc3339(),
242
+
})
243
+
.collect();
219
244
220
245
let output = SliceRecordsOutput {
221
246
success: true,
···
224
249
message: None,
225
250
};
226
251
227
-
Ok(Json(serde_json::to_value(output)
228
-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?))
229
-
},
252
+
Ok(Json(
253
+
serde_json::to_value(output).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
254
+
))
255
+
}
230
256
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
231
257
}
232
258
}
233
-
234
-
235
259
236
260
// Implementation for get record
237
261
async fn dynamic_get_record_impl(
···
239
263
state: AppState,
240
264
params: serde_json::Value,
241
265
) -> Result<Json<serde_json::Value>, StatusCode> {
242
-
let get_params: GetRecordParams = serde_json::from_value(params)
243
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
266
+
let get_params: GetRecordParams =
267
+
serde_json::from_value(params).map_err(|_| StatusCode::BAD_REQUEST)?;
244
268
245
269
// First verify the collection belongs to this slice
246
-
let slice_collections = state.database.get_slice_collections_list(&get_params.slice).await
270
+
let slice_collections = state
271
+
.database
272
+
.get_slice_collections_list(&get_params.slice)
273
+
.await
247
274
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
248
275
249
-
// Special handling: social.slices.lexicon is always allowed as it defines the schema
250
-
if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) {
276
+
// Special handling: network.slices.lexicon is always allowed as it defines the schema
277
+
if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) {
251
278
return Err(StatusCode::NOT_FOUND);
252
279
}
253
280
254
281
// Use direct database query by URI for efficiency
255
282
match state.database.get_record(&get_params.uri).await {
256
283
Ok(Some(record)) => {
257
-
let json_value = serde_json::to_value(record)
258
-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
284
+
let json_value =
285
+
serde_json::to_value(record).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
259
286
Ok(Json(json_value))
260
-
},
261
-
Ok(None) => {
262
-
Err(StatusCode::NOT_FOUND)
263
-
},
264
-
Err(_e) => {
265
-
Err(StatusCode::INTERNAL_SERVER_ERROR)
266
-
},
287
+
}
288
+
Ok(None) => Err(StatusCode::NOT_FOUND),
289
+
Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
267
290
}
268
291
}
269
292
···
274
297
body: serde_json::Value,
275
298
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
276
299
// Parse the JSON body into SliceRecordsParams
277
-
let mut records_params: SliceRecordsParams = serde_json::from_value(body)
278
-
.map_err(|_| (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Invalid request body"}))))?;
300
+
let mut records_params: SliceRecordsParams = serde_json::from_value(body).map_err(|_| {
301
+
(
302
+
StatusCode::BAD_REQUEST,
303
+
Json(serde_json::json!({"error": "Invalid request body"})),
304
+
)
305
+
})?;
279
306
280
307
// First verify the collection belongs to this slice
281
-
let slice_collections = state.database.get_slice_collections_list(&records_params.slice).await
282
-
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Database error"}))))?;
308
+
let slice_collections = state
309
+
.database
310
+
.get_slice_collections_list(&records_params.slice)
311
+
.await
312
+
.map_err(|_| {
313
+
(
314
+
StatusCode::INTERNAL_SERVER_ERROR,
315
+
Json(serde_json::json!({"error": "Database error"})),
316
+
)
317
+
})?;
283
318
284
-
// Special handling: social.slices.lexicon is always allowed as it defines the schema
285
-
if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) {
286
-
return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Collection not found"}))));
319
+
// Special handling: network.slices.lexicon is always allowed as it defines the schema
320
+
if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) {
321
+
return Err((
322
+
StatusCode::NOT_FOUND,
323
+
Json(serde_json::json!({"error": "Collection not found"})),
324
+
));
287
325
}
288
326
289
327
// Add collection filter to where conditions
290
-
let mut where_clause = records_params.where_clause.unwrap_or(crate::models::WhereClause {
291
-
conditions: HashMap::new(),
292
-
or_conditions: None,
293
-
});
294
-
where_clause.conditions.insert("collection".to_string(), WhereCondition {
295
-
eq: Some(serde_json::Value::String(collection.clone())),
296
-
in_values: None,
297
-
contains: None,
298
-
});
328
+
let mut where_clause = records_params
329
+
.where_clause
330
+
.unwrap_or(crate::models::WhereClause {
331
+
conditions: HashMap::new(),
332
+
or_conditions: None,
333
+
});
334
+
where_clause.conditions.insert(
335
+
"collection".to_string(),
336
+
WhereCondition {
337
+
eq: Some(serde_json::Value::String(collection.clone())),
338
+
in_values: None,
339
+
contains: None,
340
+
},
341
+
);
299
342
records_params.where_clause = Some(where_clause);
300
343
301
344
// Use the unified database method
302
-
match state.database.get_slice_collections_records(
303
-
&records_params.slice,
304
-
records_params.limit,
305
-
records_params.cursor.as_deref(),
306
-
records_params.sort_by.as_ref(),
307
-
records_params.where_clause.as_ref(),
308
-
).await {
345
+
match state
346
+
.database
347
+
.get_slice_collections_records(
348
+
&records_params.slice,
349
+
records_params.limit,
350
+
records_params.cursor.as_deref(),
351
+
records_params.sort_by.as_ref(),
352
+
records_params.where_clause.as_ref(),
353
+
)
354
+
.await
355
+
{
309
356
Ok((records, cursor)) => {
310
357
// No need to filter - collection filter is in the SQL query now
311
358
312
359
// Transform Record to IndexedRecord for the response
313
-
let indexed_records: Vec<IndexedRecord> = records.into_iter().map(|record| IndexedRecord {
314
-
uri: record.uri,
315
-
cid: record.cid,
316
-
did: record.did,
317
-
collection: record.collection,
318
-
value: record.json,
319
-
indexed_at: record.indexed_at.to_rfc3339(),
320
-
}).collect();
360
+
let indexed_records: Vec<IndexedRecord> = records
361
+
.into_iter()
362
+
.map(|record| IndexedRecord {
363
+
uri: record.uri,
364
+
cid: record.cid,
365
+
did: record.did,
366
+
collection: record.collection,
367
+
value: record.json,
368
+
indexed_at: record.indexed_at.to_rfc3339(),
369
+
})
370
+
.collect();
321
371
322
372
let output = SliceRecordsOutput {
323
373
success: true,
···
326
376
message: None,
327
377
};
328
378
329
-
Ok(Json(serde_json::to_value(output)
330
-
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Serialization error"}))))?))
331
-
},
332
-
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Database error"})))),
379
+
Ok(Json(serde_json::to_value(output).map_err(|_| {
380
+
(
381
+
StatusCode::INTERNAL_SERVER_ERROR,
382
+
Json(serde_json::json!({"error": "Serialization error"})),
383
+
)
384
+
})?))
385
+
}
386
+
Err(_) => Err((
387
+
StatusCode::INTERNAL_SERVER_ERROR,
388
+
Json(serde_json::json!({"error": "Database error"})),
389
+
)),
333
390
}
334
391
}
335
392
···
340
397
params: serde_json::Value,
341
398
) -> Result<Json<serde_json::Value>, StatusCode> {
342
399
// Convert query parameters to SliceRecordsParams
343
-
let mut records_params: SliceRecordsParams = serde_json::from_value(params)
344
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
400
+
let mut records_params: SliceRecordsParams =
401
+
serde_json::from_value(params).map_err(|_| StatusCode::BAD_REQUEST)?;
345
402
346
403
// First verify the collection belongs to this slice
347
-
let slice_collections = state.database.get_slice_collections_list(&records_params.slice).await
404
+
let slice_collections = state
405
+
.database
406
+
.get_slice_collections_list(&records_params.slice)
407
+
.await
348
408
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
349
409
350
-
// Special handling: social.slices.lexicon is always allowed as it defines the schema
351
-
if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) {
410
+
// Special handling: network.slices.lexicon is always allowed as it defines the schema
411
+
if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) {
352
412
return Err(StatusCode::NOT_FOUND);
353
413
}
354
414
355
415
// Add collection filter to where conditions
356
-
let mut where_clause = records_params.where_clause.unwrap_or(crate::models::WhereClause {
357
-
conditions: HashMap::new(),
358
-
or_conditions: None,
359
-
});
360
-
where_clause.conditions.insert("collection".to_string(), WhereCondition {
361
-
eq: Some(collection.clone().into()),
362
-
contains: None,
363
-
in_values: None,
364
-
});
416
+
let mut where_clause = records_params
417
+
.where_clause
418
+
.unwrap_or(crate::models::WhereClause {
419
+
conditions: HashMap::new(),
420
+
or_conditions: None,
421
+
});
422
+
where_clause.conditions.insert(
423
+
"collection".to_string(),
424
+
WhereCondition {
425
+
eq: Some(collection.clone().into()),
426
+
contains: None,
427
+
in_values: None,
428
+
},
429
+
);
365
430
records_params.where_clause = Some(where_clause);
366
431
367
-
match state.database.count_slice_collections_records(&records_params.slice, records_params.where_clause.as_ref()).await {
432
+
match state
433
+
.database
434
+
.count_slice_collections_records(
435
+
&records_params.slice,
436
+
records_params.where_clause.as_ref(),
437
+
)
438
+
.await
439
+
{
368
440
Ok(count) => Ok(Json(serde_json::json!({
369
441
"success": true,
370
442
"count": count,
···
385
457
body: serde_json::Value,
386
458
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
387
459
// Parse the JSON body into SliceRecordsParams
388
-
let mut records_params: SliceRecordsParams = serde_json::from_value(body)
389
-
.map_err(|_| (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Invalid request body"}))))?;
460
+
let mut records_params: SliceRecordsParams = serde_json::from_value(body).map_err(|_| {
461
+
(
462
+
StatusCode::BAD_REQUEST,
463
+
Json(serde_json::json!({"error": "Invalid request body"})),
464
+
)
465
+
})?;
390
466
391
467
// First verify the collection belongs to this slice
392
-
let slice_collections = state.database.get_slice_collections_list(&records_params.slice).await
393
-
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Database error"}))))?;
468
+
let slice_collections = state
469
+
.database
470
+
.get_slice_collections_list(&records_params.slice)
471
+
.await
472
+
.map_err(|_| {
473
+
(
474
+
StatusCode::INTERNAL_SERVER_ERROR,
475
+
Json(serde_json::json!({"error": "Database error"})),
476
+
)
477
+
})?;
394
478
395
-
// Special handling: social.slices.lexicon is always allowed as it defines the schema
396
-
if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) {
397
-
return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Collection not found"}))));
479
+
// Special handling: network.slices.lexicon is always allowed as it defines the schema
480
+
if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) {
481
+
return Err((
482
+
StatusCode::NOT_FOUND,
483
+
Json(serde_json::json!({"error": "Collection not found"})),
484
+
));
398
485
}
399
486
400
487
// Add collection filter to where conditions
401
-
let mut where_clause = records_params.where_clause.unwrap_or(crate::models::WhereClause {
402
-
conditions: HashMap::new(),
403
-
or_conditions: None,
404
-
});
405
-
where_clause.conditions.insert("collection".to_string(), WhereCondition {
406
-
eq: Some(collection.clone().into()),
407
-
in_values: None,
408
-
contains: None,
409
-
});
488
+
let mut where_clause = records_params
489
+
.where_clause
490
+
.unwrap_or(crate::models::WhereClause {
491
+
conditions: HashMap::new(),
492
+
or_conditions: None,
493
+
});
494
+
where_clause.conditions.insert(
495
+
"collection".to_string(),
496
+
WhereCondition {
497
+
eq: Some(collection.clone().into()),
498
+
in_values: None,
499
+
contains: None,
500
+
},
501
+
);
410
502
records_params.where_clause = Some(where_clause);
411
503
412
-
match state.database.count_slice_collections_records(&records_params.slice, records_params.where_clause.as_ref()).await {
504
+
match state
505
+
.database
506
+
.count_slice_collections_records(
507
+
&records_params.slice,
508
+
records_params.where_clause.as_ref(),
509
+
)
510
+
.await
511
+
{
413
512
Ok(count) => Ok(Json(serde_json::json!({
414
513
"success": true,
415
514
"count": count,
···
432
531
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
433
532
// Extract and verify OAuth token
434
533
let token = extract_bearer_token(&headers).map_err(status_to_error_response)?;
435
-
let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await
534
+
let user_info = verify_oauth_token(&token, &state.config.auth_base_url)
535
+
.await
436
536
.map_err(status_to_error_response)?;
437
537
438
538
// Get AT Protocol DPoP auth and PDS URL
439
-
let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url).await
539
+
let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url)
540
+
.await
440
541
.map_err(status_to_error_response)?;
441
542
442
543
// Extract the repo DID from user info
···
446
547
let http_client = reqwest::Client::new();
447
548
448
549
// Extract slice URI, rkey, and record value from structured body
449
-
let slice_uri = body.get("slice")
550
+
let slice_uri = body
551
+
.get("slice")
450
552
.and_then(|v| v.as_str())
451
553
.ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))?
452
554
.to_string();
453
555
454
-
let record_key = body.get("rkey")
556
+
let record_key = body
557
+
.get("rkey")
455
558
.and_then(|v| v.as_str())
456
559
.filter(|s| !s.is_empty()) // Filter out empty strings
457
560
.map(|s| s.to_string());
458
561
459
-
let record_data = body.get("record")
562
+
let record_data = body
563
+
.get("record")
460
564
.ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))?
461
565
.clone();
462
566
463
-
464
567
// Validate the record against its lexicon
465
568
466
-
// For social.slices.lexicon collection, validate against the system slice
467
-
let validation_slice_uri = if collection == "social.slices.lexicon" {
468
-
"at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q"
569
+
// For network.slices.lexicon collection, validate against the system slice
570
+
let validation_slice_uri = if collection == "network.slices.lexicon" {
571
+
"at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z"
469
572
} else {
470
573
&slice_uri
471
574
};
472
-
473
575
474
576
match LexiconValidator::for_slice(&state.database, validation_slice_uri).await {
475
577
Ok(validator) => {
476
-
477
578
// Debug: Get lexicons from the system slice to see what's there
478
-
if collection == "social.slices.lexicon" {
479
-
}
579
+
if collection == "network.slices.lexicon" {}
480
580
481
581
if let Err(e) = validator.validate_record(&collection, &record_data) {
482
-
return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({
483
-
"error": "ValidationError",
484
-
"message": format!("Lexicon validation failed: {}", e)
485
-
}))));
582
+
return Err((
583
+
StatusCode::BAD_REQUEST,
584
+
Json(serde_json::json!({
585
+
"error": "ValidationError",
586
+
"message": format!("Lexicon validation failed: {}", e)
587
+
})),
588
+
));
486
589
}
487
-
},
590
+
}
488
591
Err(e) => {
489
592
// If no lexicon found, continue without validation (backwards compatibility)
490
593
eprintln!("Could not load lexicon validator: {:?}", e);
···
502
605
validate: false,
503
606
};
504
607
505
-
506
608
let result = create_record(&http_client, &dpop_auth, &pds_url, create_request)
507
609
.await
508
-
.map_err(|_e| {
509
-
status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR)
510
-
})?;
610
+
.map_err(|_e| status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR))?;
511
611
512
612
// Extract URI and CID from the response enum
513
613
let (uri, cid) = match result {
514
-
CreateRecordResponse::StrongRef { uri, cid, .. } => {
515
-
(uri, cid)
516
-
},
614
+
CreateRecordResponse::StrongRef { uri, cid, .. } => (uri, cid),
517
615
CreateRecordResponse::Error(_e) => {
518
616
return Err(status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR));
519
-
},
617
+
}
520
618
};
521
619
522
620
// Also store in local database for indexing
···
550
648
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
551
649
// Extract and verify OAuth token
552
650
let token = extract_bearer_token(&headers).map_err(status_to_error_response)?;
553
-
let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await
651
+
let user_info = verify_oauth_token(&token, &state.config.auth_base_url)
652
+
.await
554
653
.map_err(status_to_error_response)?;
555
654
556
655
// Get AT Protocol DPoP auth and PDS URL
557
-
let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url).await
656
+
let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url)
657
+
.await
558
658
.map_err(status_to_error_response)?;
559
659
560
660
// Extract slice URI, rkey, and record value from structured body
561
-
let slice_uri = body.get("slice")
661
+
let slice_uri = body
662
+
.get("slice")
562
663
.and_then(|v| v.as_str())
563
664
.ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))?
564
665
.to_string();
565
666
566
-
let rkey = body.get("rkey")
667
+
let rkey = body
668
+
.get("rkey")
567
669
.and_then(|v| v.as_str())
568
670
.ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))?
569
671
.to_string();
570
672
571
-
let record_data = body.get("record")
673
+
let record_data = body
674
+
.get("record")
572
675
.ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))?
573
676
.clone();
574
677
···
579
682
match LexiconValidator::for_slice(&state.database, &slice_uri).await {
580
683
Ok(validator) => {
581
684
if let Err(e) = validator.validate_record(&collection, &record_data) {
582
-
return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({
583
-
"error": "ValidationError",
584
-
"message": format!("Lexicon validation failed: {}", e)
585
-
}))));
685
+
return Err((
686
+
StatusCode::BAD_REQUEST,
687
+
Json(serde_json::json!({
688
+
"error": "ValidationError",
689
+
"message": format!("Lexicon validation failed: {}", e)
690
+
})),
691
+
));
586
692
}
587
-
},
693
+
}
588
694
Err(e) => {
589
695
// If no lexicon found, continue without validation (backwards compatibility)
590
696
eprintln!("Could not load lexicon validator: {:?}", e);
···
612
718
// Extract URI and CID from the response enum
613
719
let (uri, cid) = match result {
614
720
PutRecordResponse::StrongRef { uri, cid, .. } => (uri, cid),
615
-
PutRecordResponse::Error(_) => return Err(status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR)),
721
+
PutRecordResponse::Error(_) => {
722
+
return Err(status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR));
723
+
}
616
724
};
617
725
618
726
// Also update in local database for indexing
···
644
752
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
645
753
// Extract and verify OAuth token
646
754
let token = extract_bearer_token(&headers).map_err(status_to_error_response)?;
647
-
let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await
755
+
let user_info = verify_oauth_token(&token, &state.config.auth_base_url)
756
+
.await
648
757
.map_err(status_to_error_response)?;
649
758
650
759
// Get AT Protocol DPoP auth and PDS URL
651
-
let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url).await
760
+
let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url)
761
+
.await
652
762
.map_err(status_to_error_response)?;
653
763
654
764
// Extract repo and rkey from body
655
765
let repo = user_info.did.unwrap_or(user_info.sub);
656
-
let rkey = body["rkey"].as_str()
766
+
let rkey = body["rkey"]
767
+
.as_str()
657
768
.ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))?
658
769
.to_string();
659
770
···
680
791
Ok(Json(serde_json::json!({})))
681
792
}
682
793
683
-
684
794
#[cfg(test)]
685
795
mod dynamic_validation_tests {
686
796
use crate::lexicon::LexiconValidator;
687
797
use serde_json::json;
688
798
689
799
fn create_test_lexicons() -> Vec<serde_json::Value> {
690
-
vec![
691
-
json!({
692
-
"id": "social.slices.testRecord",
693
-
"lexicon": 1,
694
-
"defs": {
695
-
"main": {
696
-
"type": "record",
697
-
"record": {
698
-
"type": "object",
699
-
"required": ["title", "aspectRatio"],
700
-
"properties": {
701
-
"title": { "type": "string", "maxLength": 100 },
702
-
"aspectRatio": { "type": "ref", "ref": "#aspectRatio" }
703
-
}
704
-
}
705
-
},
706
-
"aspectRatio": {
800
+
vec![json!({
801
+
"id": "network.slices.testRecord",
802
+
"lexicon": 1,
803
+
"defs": {
804
+
"main": {
805
+
"type": "record",
806
+
"record": {
707
807
"type": "object",
708
-
"required": ["width", "height"],
808
+
"required": ["title", "aspectRatio"],
709
809
"properties": {
710
-
"width": { "type": "integer", "minimum": 1 },
711
-
"height": { "type": "integer", "minimum": 1 }
810
+
"title": { "type": "string", "maxLength": 100 },
811
+
"aspectRatio": { "type": "ref", "ref": "#aspectRatio" }
712
812
}
713
813
}
814
+
},
815
+
"aspectRatio": {
816
+
"type": "object",
817
+
"required": ["width", "height"],
818
+
"properties": {
819
+
"width": { "type": "integer", "minimum": 1 },
820
+
"height": { "type": "integer", "minimum": 1 }
821
+
}
714
822
}
715
-
})
716
-
]
823
+
}
824
+
})]
717
825
}
718
826
719
827
#[test]
···
729
837
}
730
838
});
731
839
732
-
let result = validator.validate_record("social.slices.testRecord", &valid_record);
733
-
assert!(result.is_ok(), "Valid record should pass validation: {:?}", result);
840
+
let result = validator.validate_record("network.slices.testRecord", &valid_record);
841
+
assert!(
842
+
result.is_ok(),
843
+
"Valid record should pass validation: {:?}",
844
+
result
845
+
);
734
846
}
735
847
736
848
#[test]
···
743
855
// Missing required aspectRatio field
744
856
});
745
857
746
-
let result = validator.validate_record("social.slices.testRecord", &invalid_record);
747
-
assert!(result.is_err(), "Record missing required field should fail validation");
858
+
let result = validator.validate_record("network.slices.testRecord", &invalid_record);
859
+
assert!(
860
+
result.is_err(),
861
+
"Record missing required field should fail validation"
862
+
);
748
863
}
749
864
750
865
#[test]
···
761
876
}
762
877
});
763
878
764
-
let result = validator.validate_record("social.slices.testRecord", &valid_record);
765
-
assert!(result.is_ok(), "Cross-lexicon reference should work: {:?}", result);
879
+
let result = validator.validate_record("network.slices.testRecord", &valid_record);
880
+
assert!(
881
+
result.is_ok(),
882
+
"Cross-lexicon reference should work: {:?}",
883
+
result
884
+
);
766
885
}
767
886
768
887
#[test]
···
776
895
// Missing required aspectRatio field
777
896
});
778
897
779
-
let result = validator.validate_record("social.slices.testRecord", &invalid_record);
898
+
let result = validator.validate_record("network.slices.testRecord", &invalid_record);
780
899
assert!(result.is_err(), "Invalid record should fail validation");
781
900
782
901
if let Err(e) = result {
783
902
let error_message = format!("{}", e);
784
-
assert!(!error_message.is_empty(), "Error message should not be empty");
903
+
assert!(
904
+
!error_message.is_empty(),
905
+
"Error message should not be empty"
906
+
);
785
907
// Error message should be user-friendly and descriptive
786
-
assert!(error_message.contains("aspectRatio") || error_message.contains("required"),
787
-
"Error message should indicate what's wrong: {}", error_message);
908
+
assert!(
909
+
error_message.contains("aspectRatio") || error_message.contains("required"),
910
+
"Error message should indicate what's wrong: {}",
911
+
error_message
912
+
);
788
913
}
789
914
}
790
915
···
802
927
}
803
928
});
804
929
805
-
let result = validator.validate_record("social.slices.testRecord", &invalid_record);
806
-
assert!(result.is_err(), "Constraint violation should fail validation");
930
+
let result = validator.validate_record("network.slices.testRecord", &invalid_record);
931
+
assert!(
932
+
result.is_err(),
933
+
"Constraint violation should fail validation"
934
+
);
807
935
808
936
if let Err(e) = result {
809
937
let error_message = format!("{}", e);
810
938
// Should indicate the specific constraint that was violated
811
-
assert!(error_message.contains("length") || error_message.contains("maximum") || error_message.contains("100"),
812
-
"Error message should indicate length constraint: {}", error_message);
939
+
assert!(
940
+
error_message.contains("length")
941
+
|| error_message.contains("maximum")
942
+
|| error_message.contains("100"),
943
+
"Error message should indicate length constraint: {}",
944
+
error_message
945
+
);
813
946
}
814
947
}
815
948
}
+561
-297
api/src/lexicon/test.rs
+561
-297
api/src/lexicon/test.rs
···
19
19
"required": [
20
20
"object",
21
21
"array",
22
-
"boolean",
22
+
"boolean",
23
23
"integer",
24
24
"string",
25
25
"bytes",
···
408
408
}
409
409
}
410
410
}
411
-
})
411
+
}),
412
412
]
413
413
}
414
414
415
415
#[test]
416
416
fn test_kitchen_sink_validation() {
417
417
let validator = LexiconValidator::new(get_test_lexicons()).unwrap();
418
-
418
+
419
419
// Valid kitchen sink record (mirroring TypeScript test)
420
420
let valid_record = json!({
421
421
"object": {
···
434
434
"$link": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a"
435
435
}
436
436
});
437
-
438
-
assert!(validator.validate_record("com.example.kitchenSink", &valid_record).is_ok());
439
-
437
+
438
+
assert!(
439
+
validator
440
+
.validate_record("com.example.kitchenSink", &valid_record)
441
+
.is_ok()
442
+
);
443
+
440
444
// Missing required field (object)
441
445
let invalid_record = json!({
442
446
"array": ["one", "two"],
···
448
452
"$link": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a"
449
453
}
450
454
});
451
-
452
-
assert!(validator.validate_record("com.example.kitchenSink", &invalid_record).is_err());
455
+
456
+
assert!(
457
+
validator
458
+
.validate_record("com.example.kitchenSink", &invalid_record)
459
+
.is_err()
460
+
);
453
461
}
454
-
462
+
455
463
#[test]
456
464
fn test_local_refs() {
457
465
let validator = LexiconValidator::new(get_test_lexicons()).unwrap();
458
-
466
+
459
467
// Valid with local ref (#object -> #subobject)
460
468
let valid_record = json!({
461
469
"object": { "boolean": true }, // subobject ref
···
464
472
"integer": 123,
465
473
"string": "string"
466
474
});
467
-
468
-
assert!(validator.validate_record("com.example.kitchenSink#object", &valid_record).is_ok());
469
-
475
+
476
+
assert!(
477
+
validator
478
+
.validate_record("com.example.kitchenSink#object", &valid_record)
479
+
.is_ok()
480
+
);
481
+
470
482
// Invalid - missing boolean in subobject
471
483
let invalid_record = json!({
472
484
"object": {}, // missing required boolean
473
-
"array": ["one", "two"],
485
+
"array": ["one", "two"],
474
486
"boolean": true,
475
487
"integer": 123,
476
488
"string": "string"
477
489
});
478
-
479
-
assert!(validator.validate_record("com.example.kitchenSink#object", &invalid_record).is_err());
490
+
491
+
assert!(
492
+
validator
493
+
.validate_record("com.example.kitchenSink#object", &invalid_record)
494
+
.is_err()
495
+
);
480
496
}
481
-
497
+
482
498
#[test]
483
499
fn test_array_length_constraints() {
484
500
let validator = LexiconValidator::new(get_test_lexicons()).unwrap();
485
-
501
+
486
502
// Valid array length
487
503
let valid = json!({ "array": [1, 2, 3] });
488
-
assert!(validator.validate_record("com.example.arrayLength", &valid).is_ok());
489
-
504
+
assert!(
505
+
validator
506
+
.validate_record("com.example.arrayLength", &valid)
507
+
.is_ok()
508
+
);
509
+
490
510
// Too short
491
511
let too_short = json!({ "array": [1] });
492
-
assert!(validator.validate_record("com.example.arrayLength", &too_short).is_err());
493
-
512
+
assert!(
513
+
validator
514
+
.validate_record("com.example.arrayLength", &too_short)
515
+
.is_err()
516
+
);
517
+
494
518
// Too long
495
519
let too_long = json!({ "array": [1, 2, 3, 4, 5] });
496
-
assert!(validator.validate_record("com.example.arrayLength", &too_long).is_err());
497
-
520
+
assert!(
521
+
validator
522
+
.validate_record("com.example.arrayLength", &too_long)
523
+
.is_err()
524
+
);
525
+
498
526
// Wrong item type
499
527
let wrong_type = json!({ "array": [1, "2", 3] });
500
-
assert!(validator.validate_record("com.example.arrayLength", &wrong_type).is_err());
528
+
assert!(
529
+
validator
530
+
.validate_record("com.example.arrayLength", &wrong_type)
531
+
.is_err()
532
+
);
501
533
}
502
-
534
+
503
535
#[test]
504
536
fn test_boolean_const_constraint() {
505
537
let validator = LexiconValidator::new(get_test_lexicons()).unwrap();
506
-
538
+
507
539
// Valid const
508
540
let valid = json!({ "boolean": false });
509
-
assert!(validator.validate_record("com.example.boolConst", &valid).is_ok());
510
-
541
+
assert!(
542
+
validator
543
+
.validate_record("com.example.boolConst", &valid)
544
+
.is_ok()
545
+
);
546
+
511
547
// Invalid const
512
548
let invalid = json!({ "boolean": true });
513
-
assert!(validator.validate_record("com.example.boolConst", &invalid).is_err());
549
+
assert!(
550
+
validator
551
+
.validate_record("com.example.boolConst", &invalid)
552
+
.is_err()
553
+
);
514
554
}
515
-
555
+
516
556
#[test]
517
557
fn test_integer_constraints() {
518
558
let validator = LexiconValidator::new(get_test_lexicons()).unwrap();
519
-
559
+
520
560
// Range test - valid
521
561
let valid_range = json!({ "integer": 2 });
522
-
assert!(validator.validate_record("com.example.integerRange", &valid_range).is_ok());
523
-
562
+
assert!(
563
+
validator
564
+
.validate_record("com.example.integerRange", &valid_range)
565
+
.is_ok()
566
+
);
567
+
524
568
// Range test - too low
525
569
let too_low = json!({ "integer": 1 });
526
-
assert!(validator.validate_record("com.example.integerRange", &too_low).is_err());
527
-
528
-
// Range test - too high
570
+
assert!(
571
+
validator
572
+
.validate_record("com.example.integerRange", &too_low)
573
+
.is_err()
574
+
);
575
+
576
+
// Range test - too high
529
577
let too_high = json!({ "integer": 5 });
530
-
assert!(validator.validate_record("com.example.integerRange", &too_high).is_err());
531
-
578
+
assert!(
579
+
validator
580
+
.validate_record("com.example.integerRange", &too_high)
581
+
.is_err()
582
+
);
583
+
532
584
// Enum test - valid
533
585
let valid_enum = json!({ "integer": 2 });
534
-
assert!(validator.validate_record("com.example.integerEnum", &valid_enum).is_ok());
535
-
586
+
assert!(
587
+
validator
588
+
.validate_record("com.example.integerEnum", &valid_enum)
589
+
.is_ok()
590
+
);
591
+
536
592
// Enum test - invalid
537
593
let invalid_enum = json!({ "integer": 0 });
538
-
assert!(validator.validate_record("com.example.integerEnum", &invalid_enum).is_err());
539
-
594
+
assert!(
595
+
validator
596
+
.validate_record("com.example.integerEnum", &invalid_enum)
597
+
.is_err()
598
+
);
599
+
540
600
// Const test - valid
541
601
let valid_const = json!({ "integer": 0 });
542
-
assert!(validator.validate_record("com.example.integerConst", &valid_const).is_ok());
543
-
602
+
assert!(
603
+
validator
604
+
.validate_record("com.example.integerConst", &valid_const)
605
+
.is_ok()
606
+
);
607
+
544
608
// Const test - invalid
545
609
let invalid_const = json!({ "integer": 1 });
546
-
assert!(validator.validate_record("com.example.integerConst", &invalid_const).is_err());
610
+
assert!(
611
+
validator
612
+
.validate_record("com.example.integerConst", &invalid_const)
613
+
.is_err()
614
+
);
547
615
}
548
-
616
+
549
617
#[test]
550
618
fn test_string_constraints() {
551
619
let validator = LexiconValidator::new(get_test_lexicons()).unwrap();
552
-
620
+
553
621
// Length test - valid
554
622
let valid_length = json!({ "string": "ab" });
555
-
assert!(validator.validate_record("com.example.stringLength", &valid_length).is_ok());
556
-
623
+
assert!(
624
+
validator
625
+
.validate_record("com.example.stringLength", &valid_length)
626
+
.is_ok()
627
+
);
628
+
557
629
let valid_length_max = json!({ "string": "abcd" });
558
-
assert!(validator.validate_record("com.example.stringLength", &valid_length_max).is_ok());
559
-
630
+
assert!(
631
+
validator
632
+
.validate_record("com.example.stringLength", &valid_length_max)
633
+
.is_ok()
634
+
);
635
+
560
636
// Length test - too short
561
637
let too_short = json!({ "string": "a" });
562
-
assert!(validator.validate_record("com.example.stringLength", &too_short).is_err());
563
-
638
+
assert!(
639
+
validator
640
+
.validate_record("com.example.stringLength", &too_short)
641
+
.is_err()
642
+
);
643
+
564
644
// Length test - too long
565
645
let too_long = json!({ "string": "abcde" });
566
-
assert!(validator.validate_record("com.example.stringLength", &too_long).is_err());
567
-
646
+
assert!(
647
+
validator
648
+
.validate_record("com.example.stringLength", &too_long)
649
+
.is_err()
650
+
);
651
+
568
652
// Enum test - valid
569
653
let valid_enum = json!({ "string": "a" });
570
-
assert!(validator.validate_record("com.example.stringEnum", &valid_enum).is_ok());
571
-
654
+
assert!(
655
+
validator
656
+
.validate_record("com.example.stringEnum", &valid_enum)
657
+
.is_ok()
658
+
);
659
+
572
660
// Enum test - invalid
573
661
let invalid_enum = json!({ "string": "c" });
574
-
assert!(validator.validate_record("com.example.stringEnum", &invalid_enum).is_err());
575
-
662
+
assert!(
663
+
validator
664
+
.validate_record("com.example.stringEnum", &invalid_enum)
665
+
.is_err()
666
+
);
667
+
576
668
// Const test - valid
577
669
let valid_const = json!({ "string": "a" });
578
-
assert!(validator.validate_record("com.example.stringConst", &valid_const).is_ok());
579
-
670
+
assert!(
671
+
validator
672
+
.validate_record("com.example.stringConst", &valid_const)
673
+
.is_ok()
674
+
);
675
+
580
676
// Const test - invalid
581
677
let invalid_const = json!({ "string": "b" });
582
-
assert!(validator.validate_record("com.example.stringConst", &invalid_const).is_err());
678
+
assert!(
679
+
validator
680
+
.validate_record("com.example.stringConst", &invalid_const)
681
+
.is_err()
682
+
);
583
683
}
584
-
684
+
585
685
#[test]
586
686
fn test_string_formats() {
587
687
let validator = LexiconValidator::new(get_test_lexicons()).unwrap();
588
-
688
+
589
689
// DateTime format tests (matching TypeScript test cases)
590
690
let valid_datetimes = vec![
591
691
"2022-12-12T00:50:36.809Z",
592
-
"2022-12-12T00:50:36Z",
692
+
"2022-12-12T00:50:36Z",
593
693
"2022-12-12T00:50:36.8Z",
594
694
"2022-12-12T00:50:36.80Z",
595
695
"2022-12-12T00:50:36+00:00",
···
597
697
"2022-12-11T19:50:36-05:00",
598
698
"2022-12-11T19:50:36.8-05:00",
599
699
"2022-12-11T19:50:36.80-05:00",
600
-
"2022-12-11T19:50:36.809-05:00"
700
+
"2022-12-11T19:50:36.809-05:00",
601
701
];
602
-
702
+
603
703
for datetime in valid_datetimes {
604
704
let record = json!({ "datetime": datetime });
605
-
assert!(validator.validate_record("com.example.datetime", &record).is_ok(),
606
-
"Should accept datetime: {}", datetime);
705
+
assert!(
706
+
validator
707
+
.validate_record("com.example.datetime", &record)
708
+
.is_ok(),
709
+
"Should accept datetime: {}",
710
+
datetime
711
+
);
607
712
}
608
-
713
+
609
714
let invalid_datetime = json!({ "datetime": "bad date" });
610
-
assert!(validator.validate_record("com.example.datetime", &invalid_datetime).is_err());
611
-
715
+
assert!(
716
+
validator
717
+
.validate_record("com.example.datetime", &invalid_datetime)
718
+
.is_err()
719
+
);
720
+
612
721
// URI format tests
613
722
let valid_uris = vec![
614
723
"https://example.com",
615
724
"https://example.com/with/path",
616
725
"https://example.com/with/path?and=query",
617
-
"at://bsky.social",
618
-
"did:example:test"
726
+
"at://bsky.social",
727
+
"did:example:test",
619
728
];
620
-
729
+
621
730
for uri in valid_uris {
622
731
let record = json!({ "uri": uri });
623
-
assert!(validator.validate_record("com.example.uri", &record).is_ok(),
624
-
"Should accept URI: {}", uri);
732
+
assert!(
733
+
validator
734
+
.validate_record("com.example.uri", &record)
735
+
.is_ok(),
736
+
"Should accept URI: {}",
737
+
uri
738
+
);
625
739
}
626
-
740
+
627
741
let invalid_uri = json!({ "uri": "not a uri" });
628
-
assert!(validator.validate_record("com.example.uri", &invalid_uri).is_err());
629
-
742
+
assert!(
743
+
validator
744
+
.validate_record("com.example.uri", &invalid_uri)
745
+
.is_err()
746
+
);
747
+
630
748
// AT-URI format test
631
749
let valid_at_uri = json!({ "atUri": "at://did:web:example.com/com.example.test/self" });
632
-
assert!(validator.validate_record("com.example.atUri", &valid_at_uri).is_ok());
633
-
750
+
assert!(
751
+
validator
752
+
.validate_record("com.example.atUri", &valid_at_uri)
753
+
.is_ok()
754
+
);
755
+
634
756
let invalid_at_uri = json!({ "atUri": "http://not-atproto.com" });
635
-
assert!(validator.validate_record("com.example.atUri", &invalid_at_uri).is_err());
636
-
637
-
// DID format tests
638
-
let valid_dids = vec![
639
-
"did:web:example.com",
640
-
"did:plc:12345678abcdefghijklmnop"
641
-
];
642
-
757
+
assert!(
758
+
validator
759
+
.validate_record("com.example.atUri", &invalid_at_uri)
760
+
.is_err()
761
+
);
762
+
763
+
// DID format tests
764
+
let valid_dids = vec!["did:web:example.com", "did:plc:12345678abcdefghijklmnop"];
765
+
643
766
for did in valid_dids {
644
767
let record = json!({ "did": did });
645
-
assert!(validator.validate_record("com.example.did", &record).is_ok(),
646
-
"Should accept DID: {}", did);
768
+
assert!(
769
+
validator
770
+
.validate_record("com.example.did", &record)
771
+
.is_ok(),
772
+
"Should accept DID: {}",
773
+
did
774
+
);
647
775
}
648
-
776
+
649
777
let invalid_dids = vec!["bad did", "did:short"];
650
-
778
+
651
779
for did in invalid_dids {
652
780
let record = json!({ "did": did });
653
-
assert!(validator.validate_record("com.example.did", &record).is_err(),
654
-
"Should reject DID: {}", did);
781
+
assert!(
782
+
validator
783
+
.validate_record("com.example.did", &record)
784
+
.is_err(),
785
+
"Should reject DID: {}",
786
+
did
787
+
);
655
788
}
656
-
789
+
657
790
// Handle format tests
658
-
let valid_handles = vec![
659
-
"test.bsky.social",
660
-
"bsky.test"
661
-
];
662
-
791
+
let valid_handles = vec!["test.bsky.social", "bsky.test"];
792
+
663
793
for handle in valid_handles {
664
794
let record = json!({ "handle": handle });
665
-
assert!(validator.validate_record("com.example.handle", &record).is_ok(),
666
-
"Should accept handle: {}", handle);
795
+
assert!(
796
+
validator
797
+
.validate_record("com.example.handle", &record)
798
+
.is_ok(),
799
+
"Should accept handle: {}",
800
+
handle
801
+
);
667
802
}
668
-
803
+
669
804
let invalid_handles = vec!["bad handle", "-bad-.test"];
670
-
805
+
671
806
for handle in invalid_handles {
672
807
let record = json!({ "handle": handle });
673
-
assert!(validator.validate_record("com.example.handle", &record).is_err(),
674
-
"Should reject handle: {}", handle);
808
+
assert!(
809
+
validator
810
+
.validate_record("com.example.handle", &record)
811
+
.is_err(),
812
+
"Should reject handle: {}",
813
+
handle
814
+
);
675
815
}
676
-
816
+
677
817
// AT-identifier format tests
678
-
let valid_at_identifiers = vec![
679
-
"bsky.test",
680
-
"did:plc:12345678abcdefghijklmnop"
681
-
];
682
-
818
+
let valid_at_identifiers = vec!["bsky.test", "did:plc:12345678abcdefghijklmnop"];
819
+
683
820
for at_id in valid_at_identifiers {
684
821
let record = json!({ "atIdentifier": at_id });
685
-
assert!(validator.validate_record("com.example.atIdentifier", &record).is_ok(),
686
-
"Should accept at-identifier: {}", at_id);
822
+
assert!(
823
+
validator
824
+
.validate_record("com.example.atIdentifier", &record)
825
+
.is_ok(),
826
+
"Should accept at-identifier: {}",
827
+
at_id
828
+
);
687
829
}
688
-
830
+
689
831
let invalid_at_identifiers = vec!["bad id", "-bad-.test"];
690
-
832
+
691
833
for at_id in invalid_at_identifiers {
692
834
let record = json!({ "atIdentifier": at_id });
693
-
assert!(validator.validate_record("com.example.atIdentifier", &record).is_err(),
694
-
"Should reject at-identifier: {}", at_id);
835
+
assert!(
836
+
validator
837
+
.validate_record("com.example.atIdentifier", &record)
838
+
.is_err(),
839
+
"Should reject at-identifier: {}",
840
+
at_id
841
+
);
695
842
}
696
-
843
+
697
844
// NSID format tests
698
-
let valid_nsids = vec![
699
-
"com.atproto.test",
700
-
"app.bsky.nested.test"
701
-
];
702
-
845
+
let valid_nsids = vec!["com.atproto.test", "app.bsky.nested.test"];
846
+
703
847
for nsid in valid_nsids {
704
848
let record = json!({ "nsid": nsid });
705
-
assert!(validator.validate_record("com.example.nsid", &record).is_ok(),
706
-
"Should accept NSID: {}", nsid);
849
+
assert!(
850
+
validator
851
+
.validate_record("com.example.nsid", &record)
852
+
.is_ok(),
853
+
"Should accept NSID: {}",
854
+
nsid
855
+
);
707
856
}
708
-
857
+
709
858
let invalid_nsids = vec!["bad nsid", "com.bad-.foo"];
710
-
859
+
711
860
for nsid in invalid_nsids {
712
861
let record = json!({ "nsid": nsid });
713
-
assert!(validator.validate_record("com.example.nsid", &record).is_err(),
714
-
"Should reject NSID: {}", nsid);
862
+
assert!(
863
+
validator
864
+
.validate_record("com.example.nsid", &record)
865
+
.is_err(),
866
+
"Should reject NSID: {}",
867
+
nsid
868
+
);
715
869
}
716
-
870
+
717
871
// CID format test
718
-
let valid_cid = json!({ "cid": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" });
719
-
assert!(validator.validate_record("com.example.cid", &valid_cid).is_ok());
720
-
872
+
let valid_cid =
873
+
json!({ "cid": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" });
874
+
assert!(
875
+
validator
876
+
.validate_record("com.example.cid", &valid_cid)
877
+
.is_ok()
878
+
);
879
+
721
880
let invalid_cid = json!({ "cid": "abapsdofiuwrpoiasdfuaspdfoiu" });
722
-
assert!(validator.validate_record("com.example.cid", &invalid_cid).is_err());
723
-
881
+
assert!(
882
+
validator
883
+
.validate_record("com.example.cid", &invalid_cid)
884
+
.is_err()
885
+
);
886
+
724
887
// Language format test
725
888
let valid_language = json!({ "language": "en-US-boont" });
726
-
assert!(validator.validate_record("com.example.language", &valid_language).is_ok());
727
-
889
+
assert!(
890
+
validator
891
+
.validate_record("com.example.language", &valid_language)
892
+
.is_ok()
893
+
);
894
+
728
895
let invalid_language = json!({ "language": "not-a-language-" });
729
-
assert!(validator.validate_record("com.example.language", &invalid_language).is_err());
896
+
assert!(
897
+
validator
898
+
.validate_record("com.example.language", &invalid_language)
899
+
.is_err()
900
+
);
730
901
}
731
-
902
+
732
903
#[test]
733
904
fn test_bytes_validation() {
734
905
let validator = LexiconValidator::new(get_test_lexicons()).unwrap();
735
-
906
+
736
907
// Valid bytes (base64, 3 bytes when decoded)
737
908
let valid_bytes = json!({ "bytes": "SGVs" }); // 3 bytes when decoded
738
-
assert!(validator.validate_record("com.example.byteLength", &valid_bytes).is_ok());
739
-
909
+
assert!(
910
+
validator
911
+
.validate_record("com.example.byteLength", &valid_bytes)
912
+
.is_ok()
913
+
);
914
+
740
915
// Too short (1 byte when decoded)
741
916
let too_short = json!({ "bytes": "SA==" });
742
-
assert!(validator.validate_record("com.example.byteLength", &too_short).is_err());
743
-
744
-
// Too long (5+ bytes when decoded)
917
+
assert!(
918
+
validator
919
+
.validate_record("com.example.byteLength", &too_short)
920
+
.is_err()
921
+
);
922
+
923
+
// Too long (5+ bytes when decoded)
745
924
let too_long = json!({ "bytes": "SGVsbG9Xb3JsZA==" }); // "HelloWorld" is 10 bytes
746
-
assert!(validator.validate_record("com.example.byteLength", &too_long).is_err());
925
+
assert!(
926
+
validator
927
+
.validate_record("com.example.byteLength", &too_long)
928
+
.is_err()
929
+
);
747
930
}
748
-
931
+
749
932
#[test]
750
933
fn test_union_validation() {
751
934
let validator = LexiconValidator::new(get_test_lexicons()).unwrap();
752
-
935
+
753
936
// Valid union - open union with object variant
754
937
let valid_union = json!({
755
938
"unionOpen": {
···
761
944
"string": "string"
762
945
},
763
946
"unionClosed": {
764
-
"$type": "com.example.kitchenSink#subobject",
947
+
"$type": "com.example.kitchenSink#subobject",
765
948
"boolean": true
766
949
}
767
950
});
768
-
769
-
assert!(validator.validate_record("com.example.union", &valid_union).is_ok());
770
-
951
+
952
+
assert!(
953
+
validator
954
+
.validate_record("com.example.union", &valid_union)
955
+
.is_ok()
956
+
);
957
+
771
958
// Valid union - open union with other type
772
959
let valid_open = json!({
773
960
"unionOpen": {
···
775
962
},
776
963
"unionClosed": {
777
964
"$type": "com.example.kitchenSink#subobject",
778
-
"boolean": true
965
+
"boolean": true
779
966
}
780
967
});
781
-
968
+
782
969
// This should work for open unions (they allow unknown types)
783
-
assert!(validator.validate_record("com.example.union", &valid_open).is_ok());
784
-
970
+
assert!(
971
+
validator
972
+
.validate_record("com.example.union", &valid_open)
973
+
.is_ok()
974
+
);
975
+
785
976
// Missing $type in union
786
977
let missing_type = json!({
787
978
"unionOpen": {},
788
979
"unionClosed": {}
789
980
});
790
-
791
-
assert!(validator.validate_record("com.example.union", &missing_type).is_err());
981
+
982
+
assert!(
983
+
validator
984
+
.validate_record("com.example.union", &missing_type)
985
+
.is_err()
986
+
);
792
987
}
793
-
988
+
794
989
#[test]
795
990
fn test_unknown_type() {
796
991
let validator = LexiconValidator::new(get_test_lexicons()).unwrap();
797
-
992
+
798
993
// Valid unknown field
799
994
let valid_unknown = json!({ "unknown": { "foo": "bar" } });
800
-
assert!(validator.validate_record("com.example.unknown", &valid_unknown).is_ok());
801
-
995
+
assert!(
996
+
validator
997
+
.validate_record("com.example.unknown", &valid_unknown)
998
+
.is_ok()
999
+
);
1000
+
802
1001
// Missing required unknown field
803
1002
let missing_unknown = json!({});
804
-
assert!(validator.validate_record("com.example.unknown", &missing_unknown).is_err());
1003
+
assert!(
1004
+
validator
1005
+
.validate_record("com.example.unknown", &missing_unknown)
1006
+
.is_err()
1007
+
);
805
1008
}
806
-
807
-
#[test]
1009
+
1010
+
#[test]
808
1011
fn test_type_validation_errors() {
809
1012
let validator = LexiconValidator::new(get_test_lexicons()).unwrap();
810
-
1013
+
811
1014
let base_record = json!({
812
1015
"object": {
813
1016
"object": { "boolean": true },
···
825
1028
"$link": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a"
826
1029
}
827
1030
});
828
-
1031
+
829
1032
// Wrong boolean type
830
1033
let mut bad_boolean = base_record.clone();
831
1034
bad_boolean["object"]["object"]["boolean"] = json!("not boolean");
832
-
assert!(validator.validate_record("com.example.kitchenSink", &bad_boolean).is_err());
833
-
834
-
// Wrong object type
1035
+
assert!(
1036
+
validator
1037
+
.validate_record("com.example.kitchenSink", &bad_boolean)
1038
+
.is_err()
1039
+
);
1040
+
1041
+
// Wrong object type
835
1042
let mut bad_object = base_record.clone();
836
1043
bad_object["object"] = json!(true);
837
-
assert!(validator.validate_record("com.example.kitchenSink", &bad_object).is_err());
838
-
1044
+
assert!(
1045
+
validator
1046
+
.validate_record("com.example.kitchenSink", &bad_object)
1047
+
.is_err()
1048
+
);
1049
+
839
1050
// Wrong array type
840
1051
let mut bad_array = base_record.clone();
841
1052
bad_array["array"] = json!(1234);
842
-
assert!(validator.validate_record("com.example.kitchenSink", &bad_array).is_err());
843
-
1053
+
assert!(
1054
+
validator
1055
+
.validate_record("com.example.kitchenSink", &bad_array)
1056
+
.is_err()
1057
+
);
1058
+
844
1059
// Wrong integer type
845
1060
let mut bad_integer = base_record.clone();
846
1061
bad_integer["integer"] = json!(true);
847
-
assert!(validator.validate_record("com.example.kitchenSink", &bad_integer).is_err());
848
-
1062
+
assert!(
1063
+
validator
1064
+
.validate_record("com.example.kitchenSink", &bad_integer)
1065
+
.is_err()
1066
+
);
1067
+
849
1068
// Wrong string type
850
1069
let mut bad_string = base_record.clone();
851
1070
bad_string["string"] = json!({});
852
-
assert!(validator.validate_record("com.example.kitchenSink", &bad_string).is_err());
853
-
1071
+
assert!(
1072
+
validator
1073
+
.validate_record("com.example.kitchenSink", &bad_string)
1074
+
.is_err()
1075
+
);
1076
+
854
1077
// Wrong bytes type
855
1078
let mut bad_bytes = base_record.clone();
856
1079
bad_bytes["bytes"] = json!(1234);
857
-
assert!(validator.validate_record("com.example.kitchenSink", &bad_bytes).is_err());
858
-
1080
+
assert!(
1081
+
validator
1082
+
.validate_record("com.example.kitchenSink", &bad_bytes)
1083
+
.is_err()
1084
+
);
1085
+
859
1086
// Wrong CID link type
860
1087
let mut bad_cid_link = base_record.clone();
861
-
bad_cid_link["cidLink"] = json!("bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a");
862
-
assert!(validator.validate_record("com.example.kitchenSink", &bad_cid_link).is_err());
1088
+
bad_cid_link["cidLink"] =
1089
+
json!("bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a");
1090
+
assert!(
1091
+
validator
1092
+
.validate_record("com.example.kitchenSink", &bad_cid_link)
1093
+
.is_err()
1094
+
);
863
1095
}
864
-
1096
+
865
1097
#[test]
866
1098
fn test_lexicon_definitions_validation() {
867
1099
let lexicons = vec![serde_json::json!({
868
-
"id": "social.slices.lexicon",
1100
+
"id": "network.slices.lexicon",
869
1101
"defs": {
870
1102
"main": {
871
1103
"type": "record",
···
880
1112
}
881
1113
}
882
1114
})];
883
-
1115
+
884
1116
let validator = LexiconValidator::new(lexicons).unwrap();
885
-
1117
+
886
1118
// Valid lexicon record with valid definitions JSON
887
1119
let valid_record = serde_json::json!({
888
1120
"nsid": "com.example.test",
889
1121
"definitions": r#"{"main": {"type": "record", "record": {"type": "object", "properties": {"title": {"type": "string"}}}}}"#
890
1122
});
891
-
892
-
let result = validator.validate_record("social.slices.lexicon", &valid_record);
893
-
assert!(result.is_ok(), "Valid lexicon definitions should pass: {:?}", result);
894
-
1123
+
1124
+
let result = validator.validate_record("network.slices.lexicon", &valid_record);
1125
+
assert!(
1126
+
result.is_ok(),
1127
+
"Valid lexicon definitions should pass: {:?}",
1128
+
result
1129
+
);
1130
+
895
1131
// Invalid JSON in definitions field
896
1132
let invalid_json_record = serde_json::json!({
897
-
"nsid": "com.example.test",
1133
+
"nsid": "com.example.test",
898
1134
"definitions": r#"{"main": invalid json}"#
899
1135
});
900
-
901
-
let result = validator.validate_record("social.slices.lexicon", &invalid_json_record);
1136
+
1137
+
let result = validator.validate_record("network.slices.lexicon", &invalid_json_record);
902
1138
assert!(result.is_err());
903
-
assert!(result.unwrap_err().to_string().contains("Invalid JSON in definitions field"));
904
-
1139
+
assert!(
1140
+
result
1141
+
.unwrap_err()
1142
+
.to_string()
1143
+
.contains("Invalid JSON in definitions field")
1144
+
);
1145
+
905
1146
// Invalid lexicon structure (missing type)
906
1147
let invalid_structure_record = serde_json::json!({
907
1148
"nsid": "com.example.test",
908
1149
"definitions": r#"{"main": {"record": {"type": "object"}}}"#
909
1150
});
910
-
911
-
let result = validator.validate_record("social.slices.lexicon", &invalid_structure_record);
1151
+
1152
+
let result = validator.validate_record("network.slices.lexicon", &invalid_structure_record);
912
1153
assert!(result.is_err());
913
-
assert!(result.unwrap_err().to_string().contains("missing required 'type' field"));
914
-
1154
+
assert!(
1155
+
result
1156
+
.unwrap_err()
1157
+
.to_string()
1158
+
.contains("missing required 'type' field")
1159
+
);
1160
+
915
1161
// Invalid definition name (with hyphen - not camelCase)
916
1162
let invalid_name_record = serde_json::json!({
917
1163
"nsid": "com.example.test",
918
1164
"definitions": r#"{"my-invalid-name": {"type": "object"}}"#
919
1165
});
920
-
921
-
let result = validator.validate_record("social.slices.lexicon", &invalid_name_record);
1166
+
1167
+
let result = validator.validate_record("network.slices.lexicon", &invalid_name_record);
922
1168
assert!(result.is_err());
923
-
assert!(result.unwrap_err().to_string().contains("must be camelCase"));
924
-
1169
+
assert!(
1170
+
result
1171
+
.unwrap_err()
1172
+
.to_string()
1173
+
.contains("must be camelCase")
1174
+
);
1175
+
925
1176
// Valid camelCase definition names should work
926
1177
let valid_camel_record = serde_json::json!({
927
1178
"nsid": "com.example.test",
928
1179
"definitions": r#"{"listViewBasic": {"type": "object"}, "starterPackView": {"type": "object"}}"#
929
1180
});
930
-
931
-
let result = validator.validate_record("social.slices.lexicon", &valid_camel_record);
932
-
assert!(result.is_ok(), "CamelCase definition names should be valid: {:?}", result);
1181
+
1182
+
let result = validator.validate_record("network.slices.lexicon", &valid_camel_record);
1183
+
assert!(
1184
+
result.is_ok(),
1185
+
"CamelCase definition names should be valid: {:?}",
1186
+
result
1187
+
);
933
1188
}
934
-
1189
+
935
1190
#[test]
936
1191
fn test_lexicon_set_completeness_validation() {
937
1192
// Test with complete lexicon set (should pass)
···
961
1216
}
962
1217
}
963
1218
}
964
-
})
1219
+
}),
965
1220
];
966
-
1221
+
967
1222
let validator = LexiconValidator::new(complete_lexicons).unwrap();
968
1223
let result = validator.validate_lexicon_set_completeness();
969
-
assert!(result.is_ok(), "Complete lexicon set should pass validation");
970
-
971
-
// Test with missing lexicon reference (should fail)
972
-
let incomplete_lexicons = vec![
973
-
serde_json::json!({
974
-
"id": "com.example.posts",
975
-
"defs": {
976
-
"main": {
977
-
"type": "record",
978
-
"record": {
979
-
"type": "object",
980
-
"properties": {
981
-
"author": { "type": "ref", "ref": "com.example.missing#user" },
982
-
"content": { "type": "string" }
983
-
}
1224
+
assert!(
1225
+
result.is_ok(),
1226
+
"Complete lexicon set should pass validation"
1227
+
);
1228
+
1229
+
// Test with missing lexicon reference (should fail)
1230
+
let incomplete_lexicons = vec![serde_json::json!({
1231
+
"id": "com.example.posts",
1232
+
"defs": {
1233
+
"main": {
1234
+
"type": "record",
1235
+
"record": {
1236
+
"type": "object",
1237
+
"properties": {
1238
+
"author": { "type": "ref", "ref": "com.example.missing#user" },
1239
+
"content": { "type": "string" }
984
1240
}
985
1241
}
986
1242
}
987
-
})
988
-
];
989
-
1243
+
}
1244
+
})];
1245
+
990
1246
let validator = LexiconValidator::new(incomplete_lexicons).unwrap();
991
1247
let result = validator.validate_lexicon_set_completeness();
992
1248
assert!(result.is_err());
993
-
assert!(result.unwrap_err().contains("Missing lexicon 'com.example.missing'"));
994
-
1249
+
assert!(
1250
+
result
1251
+
.unwrap_err()
1252
+
.contains("Missing lexicon 'com.example.missing'")
1253
+
);
1254
+
995
1255
// Test with local references (should pass - local refs are valid)
996
-
let local_ref_lexicons = vec![
997
-
serde_json::json!({
998
-
"id": "com.example.media",
999
-
"defs": {
1000
-
"main": {
1001
-
"type": "record",
1002
-
"record": {
1003
-
"type": "object",
1004
-
"properties": {
1005
-
"image": { "type": "ref", "ref": "#imageObject" }
1006
-
}
1007
-
}
1008
-
},
1009
-
"imageObject": {
1256
+
let local_ref_lexicons = vec![serde_json::json!({
1257
+
"id": "com.example.media",
1258
+
"defs": {
1259
+
"main": {
1260
+
"type": "record",
1261
+
"record": {
1010
1262
"type": "object",
1011
1263
"properties": {
1012
-
"url": { "type": "string" }
1264
+
"image": { "type": "ref", "ref": "#imageObject" }
1013
1265
}
1014
1266
}
1267
+
},
1268
+
"imageObject": {
1269
+
"type": "object",
1270
+
"properties": {
1271
+
"url": { "type": "string" }
1272
+
}
1015
1273
}
1016
-
})
1017
-
];
1018
-
1274
+
}
1275
+
})];
1276
+
1019
1277
let validator = LexiconValidator::new(local_ref_lexicons).unwrap();
1020
1278
let result = validator.validate_lexicon_set_completeness();
1021
-
assert!(result.is_ok(), "Local references should not cause validation errors");
1022
-
1279
+
assert!(
1280
+
result.is_ok(),
1281
+
"Local references should not cause validation errors"
1282
+
);
1283
+
1023
1284
// Test with direct lexicon reference (no # fragment)
1024
-
let direct_ref_lexicons = vec![
1025
-
serde_json::json!({
1026
-
"id": "com.example.gallery",
1027
-
"defs": {
1028
-
"main": {
1029
-
"type": "record",
1030
-
"record": {
1031
-
"type": "object",
1032
-
"properties": {
1033
-
"facets": {
1034
-
"type": "array",
1035
-
"items": {
1036
-
"type": "ref",
1037
-
"ref": "app.bsky.richtext.facet"
1038
-
}
1285
+
let direct_ref_lexicons = vec![serde_json::json!({
1286
+
"id": "com.example.gallery",
1287
+
"defs": {
1288
+
"main": {
1289
+
"type": "record",
1290
+
"record": {
1291
+
"type": "object",
1292
+
"properties": {
1293
+
"facets": {
1294
+
"type": "array",
1295
+
"items": {
1296
+
"type": "ref",
1297
+
"ref": "app.bsky.richtext.facet"
1039
1298
}
1040
1299
}
1041
1300
}
1042
1301
}
1043
1302
}
1044
-
})
1045
-
];
1046
-
1303
+
}
1304
+
})];
1305
+
1047
1306
let validator = LexiconValidator::new(direct_ref_lexicons).unwrap();
1048
1307
let result = validator.validate_lexicon_set_completeness();
1049
1308
assert!(result.is_err());
1050
-
assert!(result.unwrap_err().contains("Missing lexicon 'app.bsky.richtext.facet'"));
1051
-
1309
+
assert!(
1310
+
result
1311
+
.unwrap_err()
1312
+
.contains("Missing lexicon 'app.bsky.richtext.facet'")
1313
+
);
1314
+
1052
1315
// Test with union refs array
1053
-
let union_refs_lexicons = vec![
1054
-
serde_json::json!({
1055
-
"id": "com.example.content",
1056
-
"defs": {
1057
-
"main": {
1058
-
"type": "record",
1059
-
"record": {
1060
-
"type": "object",
1061
-
"properties": {
1062
-
"subject": {
1063
-
"type": "union",
1064
-
"refs": [
1065
-
"com.example.missing#post",
1066
-
"app.bsky.feed.post"
1067
-
]
1068
-
}
1316
+
let union_refs_lexicons = vec![serde_json::json!({
1317
+
"id": "com.example.content",
1318
+
"defs": {
1319
+
"main": {
1320
+
"type": "record",
1321
+
"record": {
1322
+
"type": "object",
1323
+
"properties": {
1324
+
"subject": {
1325
+
"type": "union",
1326
+
"refs": [
1327
+
"com.example.missing#post",
1328
+
"app.bsky.feed.post"
1329
+
]
1069
1330
}
1070
1331
}
1071
1332
}
1072
1333
}
1073
-
})
1074
-
];
1075
-
1334
+
}
1335
+
})];
1336
+
1076
1337
let validator = LexiconValidator::new(union_refs_lexicons).unwrap();
1077
1338
let result = validator.validate_lexicon_set_completeness();
1078
1339
assert!(result.is_err());
1079
1340
let error_msg = result.unwrap_err();
1080
1341
assert!(error_msg.contains("Missing lexicon 'com.example.missing'"));
1081
1342
assert!(error_msg.contains("Missing lexicon 'app.bsky.feed.post'"));
1082
-
1343
+
1083
1344
// Test that validation passes when all references are present
1084
1345
let complete_with_direct_refs = vec![
1085
1346
serde_json::json!({
···
1112
1373
}
1113
1374
}
1114
1375
}
1115
-
})
1376
+
}),
1116
1377
];
1117
-
1378
+
1118
1379
let validator = LexiconValidator::new(complete_with_direct_refs).unwrap();
1119
1380
let result = validator.validate_lexicon_set_completeness();
1120
-
assert!(result.is_ok(), "Should pass when all direct references are present");
1381
+
assert!(
1382
+
result.is_ok(),
1383
+
"Should pass when all direct references are present"
1384
+
);
1121
1385
}
1122
-
}
1386
+
}
+461
-266
api/src/lexicon/validator.rs
+461
-266
api/src/lexicon/validator.rs
···
1
-
use std::collections::HashMap;
2
-
use serde_json::Value;
3
1
use chrono::DateTime;
4
2
use regex::Regex;
3
+
use serde_json::Value;
4
+
use std::collections::HashMap;
5
5
6
-
use crate::database::Database;
7
6
use super::errors::ValidationError;
8
7
use super::types::{LexiconDoc, StringFormat, ValidationContext};
8
+
use crate::database::Database;
9
9
10
10
#[derive(Clone)]
11
11
pub struct LexiconValidator {
···
16
16
/// Create a new validator with the given lexicon documents
17
17
pub fn new(lexicons: Vec<Value>) -> Result<Self, ValidationError> {
18
18
let mut lexicon_map = HashMap::new();
19
-
19
+
20
20
for lexicon_value in lexicons {
21
21
let id = lexicon_value["id"]
22
22
.as_str()
23
23
.ok_or_else(|| ValidationError::InvalidSchema("Missing lexicon id".to_string()))?
24
24
.to_string();
25
-
25
+
26
26
let defs = lexicon_value["defs"].clone();
27
27
if defs.is_null() {
28
-
return Err(ValidationError::InvalidSchema(format!("Missing defs in lexicon {}", id)));
28
+
return Err(ValidationError::InvalidSchema(format!(
29
+
"Missing defs in lexicon {}",
30
+
id
31
+
)));
29
32
}
30
-
33
+
31
34
lexicon_map.insert(id.clone(), LexiconDoc { id, defs });
32
35
}
33
-
34
-
Ok(Self { lexicons: lexicon_map })
36
+
37
+
Ok(Self {
38
+
lexicons: lexicon_map,
39
+
})
35
40
}
36
-
41
+
37
42
/// Load lexicons for a specific slice from the database
38
43
pub async fn for_slice(db: &Database, slice_uri: &str) -> Result<Self, ValidationError> {
39
-
let lexicon_records = db.get_lexicons_by_slice(slice_uri)
40
-
.await
41
-
.map_err(|e| ValidationError::Unknown {
42
-
path: "database".to_string(),
43
-
message: e.to_string(),
44
-
})?;
45
-
44
+
let lexicon_records =
45
+
db.get_lexicons_by_slice(slice_uri)
46
+
.await
47
+
.map_err(|e| ValidationError::Unknown {
48
+
path: "database".to_string(),
49
+
message: e.to_string(),
50
+
})?;
51
+
46
52
// lexicon_records already has the correct format from get_lexicons_by_slice
47
53
let lexicons: Vec<serde_json::Value> = lexicon_records
48
54
.into_iter()
···
55
61
lexicon
56
62
})
57
63
.collect();
58
-
64
+
59
65
Self::new(lexicons)
60
66
}
61
-
67
+
62
68
/// Validate a record against its collection's lexicon
63
69
pub fn validate_record(&self, collection: &str, record: &Value) -> Result<(), ValidationError> {
64
70
// Parse collection string which might have fragment (#object, #main, etc)
65
71
let parts: Vec<&str> = collection.split('#').collect();
66
72
let nsid = parts[0];
67
73
let fragment = parts.get(1).map(|s| *s).unwrap_or("main");
68
-
69
-
let lexicon = self.lexicons.get(nsid)
74
+
75
+
let lexicon = self
76
+
.lexicons
77
+
.get(nsid)
70
78
.ok_or_else(|| ValidationError::LexiconNotFound(nsid.to_string()))?;
71
-
79
+
72
80
// Get the definition schema
73
81
let def_schema = &lexicon.defs[fragment];
74
82
if def_schema.is_null() {
75
-
return Err(ValidationError::InvalidSchema(
76
-
format!("No {} definition in lexicon {}", fragment, nsid)
77
-
));
83
+
return Err(ValidationError::InvalidSchema(format!(
84
+
"No {} definition in lexicon {}",
85
+
fragment, nsid
86
+
)));
78
87
}
79
-
88
+
80
89
// For record types, validate against the record schema
81
90
let record_schema = if def_schema["type"] == "record" {
82
91
&def_schema["record"]
···
84
93
// For other types like object, validate directly
85
94
def_schema
86
95
};
87
-
96
+
88
97
let ctx = ValidationContext::new();
89
-
90
-
// Special validation for social.slices.lexicon records
91
-
if nsid == "social.slices.lexicon" {
98
+
99
+
// Special validation for network.slices.lexicon records
100
+
if nsid == "network.slices.lexicon" {
92
101
self.validate_lexicon_record(record, record_schema, &ctx)?;
93
102
} else {
94
103
self.validate_value(record, record_schema, &ctx)?;
95
104
}
96
-
105
+
97
106
Ok(())
98
107
}
99
-
100
-
/// Special validation for social.slices.lexicon records
101
-
fn validate_lexicon_record(&self, record: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
108
+
109
+
/// Special validation for network.slices.lexicon records
110
+
fn validate_lexicon_record(
111
+
&self,
112
+
record: &Value,
113
+
schema: &Value,
114
+
ctx: &ValidationContext,
115
+
) -> Result<(), ValidationError> {
102
116
// First, validate against the normal schema
103
117
self.validate_value(record, schema, ctx)?;
104
-
118
+
105
119
// Then, validate that the "definitions" field contains valid lexicon JSON
106
120
if let Some(definitions_str) = record.get("definitions").and_then(|v| v.as_str()) {
107
121
// Parse the definitions JSON
108
-
let definitions_json: Value = serde_json::from_str(definitions_str)
109
-
.map_err(|e| ValidationError::InvalidSchema(
110
-
format!("Invalid JSON in definitions field: {}", e)
111
-
))?;
112
-
122
+
let definitions_json: Value = serde_json::from_str(definitions_str).map_err(|e| {
123
+
ValidationError::InvalidSchema(format!("Invalid JSON in definitions field: {}", e))
124
+
})?;
125
+
113
126
// Validate that it looks like a proper lexicon definition
114
127
self.validate_lexicon_definitions(&definitions_json)?;
115
128
}
116
-
129
+
117
130
Ok(())
118
131
}
119
-
132
+
120
133
/// Validate that a JSON value contains valid lexicon definitions
121
134
fn validate_lexicon_definitions(&self, definitions: &Value) -> Result<(), ValidationError> {
122
-
let definitions_obj = definitions.as_object()
123
-
.ok_or_else(|| ValidationError::InvalidSchema(
124
-
"Lexicon definitions must be a JSON object".to_string()
125
-
))?;
126
-
135
+
let definitions_obj = definitions.as_object().ok_or_else(|| {
136
+
ValidationError::InvalidSchema("Lexicon definitions must be a JSON object".to_string())
137
+
})?;
138
+
127
139
// Each key should be a valid definition name, each value should be a valid definition
128
140
for (def_name, def_value) in definitions_obj {
129
141
// Validate definition name (should be camelCase identifier - letters and numbers only)
130
142
if def_name.is_empty() || !def_name.chars().all(|c| c.is_ascii_alphanumeric()) {
131
-
return Err(ValidationError::InvalidSchema(
132
-
format!("Invalid definition name '{}': must be camelCase (letters and numbers only)", def_name)
133
-
));
143
+
return Err(ValidationError::InvalidSchema(format!(
144
+
"Invalid definition name '{}': must be camelCase (letters and numbers only)",
145
+
def_name
146
+
)));
134
147
}
135
-
148
+
136
149
// Validate definition structure
137
-
let def_type = def_value.get("type")
150
+
let def_type = def_value
151
+
.get("type")
138
152
.and_then(|v| v.as_str())
139
-
.ok_or_else(|| ValidationError::InvalidSchema(
140
-
format!("Definition '{}' missing required 'type' field", def_name)
141
-
))?;
142
-
153
+
.ok_or_else(|| {
154
+
ValidationError::InvalidSchema(format!(
155
+
"Definition '{}' missing required 'type' field",
156
+
def_name
157
+
))
158
+
})?;
159
+
143
160
// Validate based on definition type
144
161
match def_type {
145
162
"record" => self.validate_record_definition(def_name, def_value)?,
146
163
"object" => self.validate_object_definition(def_name, def_value)?,
147
-
"string" | "integer" | "boolean" | "array" | "union" | "ref" | "blob" | "bytes" | "cid-link" | "unknown" => {
164
+
"string" | "integer" | "boolean" | "array" | "union" | "ref" | "blob" | "bytes"
165
+
| "cid-link" | "unknown" => {
148
166
// Basic types are valid, could add more specific validation here
149
-
},
150
-
_ => return Err(ValidationError::InvalidSchema(
151
-
format!("Definition '{}' has unknown type '{}'", def_name, def_type)
152
-
))
167
+
}
168
+
_ => {
169
+
return Err(ValidationError::InvalidSchema(format!(
170
+
"Definition '{}' has unknown type '{}'",
171
+
def_name, def_type
172
+
)));
173
+
}
153
174
}
154
175
}
155
-
176
+
156
177
Ok(())
157
178
}
158
-
179
+
159
180
/// Validate a record definition structure
160
-
fn validate_record_definition(&self, def_name: &str, def_value: &Value) -> Result<(), ValidationError> {
181
+
fn validate_record_definition(
182
+
&self,
183
+
def_name: &str,
184
+
def_value: &Value,
185
+
) -> Result<(), ValidationError> {
161
186
// Record definitions should have a "record" field
162
-
let record_def = def_value.get("record")
163
-
.ok_or_else(|| ValidationError::InvalidSchema(
164
-
format!("Record definition '{}' missing 'record' field", def_name)
165
-
))?;
166
-
187
+
let record_def = def_value.get("record").ok_or_else(|| {
188
+
ValidationError::InvalidSchema(format!(
189
+
"Record definition '{}' missing 'record' field",
190
+
def_name
191
+
))
192
+
})?;
193
+
167
194
// The record field should be an object type
168
195
if record_def.get("type").and_then(|v| v.as_str()) != Some("object") {
169
-
return Err(ValidationError::InvalidSchema(
170
-
format!("Record definition '{}' record field must be type 'object'", def_name)
171
-
));
196
+
return Err(ValidationError::InvalidSchema(format!(
197
+
"Record definition '{}' record field must be type 'object'",
198
+
def_name
199
+
)));
172
200
}
173
-
201
+
174
202
// Validate properties if they exist
175
203
if let Some(properties) = record_def.get("properties") {
176
204
if !properties.is_object() {
177
-
return Err(ValidationError::InvalidSchema(
178
-
format!("Record definition '{}' properties must be an object", def_name)
179
-
));
205
+
return Err(ValidationError::InvalidSchema(format!(
206
+
"Record definition '{}' properties must be an object",
207
+
def_name
208
+
)));
180
209
}
181
210
}
182
-
211
+
183
212
Ok(())
184
213
}
185
-
186
-
/// Validate an object definition structure
187
-
fn validate_object_definition(&self, def_name: &str, def_value: &Value) -> Result<(), ValidationError> {
214
+
215
+
/// Validate an object definition structure
216
+
fn validate_object_definition(
217
+
&self,
218
+
def_name: &str,
219
+
def_value: &Value,
220
+
) -> Result<(), ValidationError> {
188
221
// Object definitions should have properties
189
222
if let Some(properties) = def_value.get("properties") {
190
223
if !properties.is_object() {
191
-
return Err(ValidationError::InvalidSchema(
192
-
format!("Object definition '{}' properties must be an object", def_name)
193
-
));
224
+
return Err(ValidationError::InvalidSchema(format!(
225
+
"Object definition '{}' properties must be an object",
226
+
def_name
227
+
)));
194
228
}
195
229
}
196
-
230
+
197
231
// Validate required field if it exists
198
232
if let Some(required) = def_value.get("required") {
199
233
if !required.is_array() {
200
-
return Err(ValidationError::InvalidSchema(
201
-
format!("Object definition '{}' required field must be an array", def_name)
202
-
));
234
+
return Err(ValidationError::InvalidSchema(format!(
235
+
"Object definition '{}' required field must be an array",
236
+
def_name
237
+
)));
203
238
}
204
239
}
205
-
240
+
206
241
Ok(())
207
242
}
208
-
243
+
209
244
/// Validate that all cross-lexicon references can be resolved within the current lexicon set
210
245
/// This is used before code generation to ensure no missing references
211
246
pub fn validate_lexicon_set_completeness(&self) -> Result<(), String> {
212
247
let mut missing_refs = Vec::new();
213
-
248
+
214
249
for (lexicon_id, lexicon) in &self.lexicons {
215
250
self.collect_missing_references(lexicon_id, &lexicon.defs, &mut missing_refs)?;
216
251
}
217
-
252
+
218
253
if missing_refs.is_empty() {
219
254
Ok(())
220
255
} else {
221
256
Err(missing_refs.join(", "))
222
257
}
223
258
}
224
-
259
+
225
260
/// Recursively collect missing cross-lexicon references
226
-
fn collect_missing_references(&self, current_lexicon: &str, value: &Value, missing_refs: &mut Vec<String>) -> Result<(), String> {
261
+
fn collect_missing_references(
262
+
&self,
263
+
current_lexicon: &str,
264
+
value: &Value,
265
+
missing_refs: &mut Vec<String>,
266
+
) -> Result<(), String> {
227
267
match value {
228
268
Value::Object(obj) => {
229
269
// Check for ref field
···
236
276
if parts.len() == 2 {
237
277
let target_lexicon = parts[0];
238
278
if !self.lexicons.contains_key(target_lexicon) {
239
-
let missing_ref = format!("Missing lexicon '{}' (referenced as '{}')", target_lexicon, ref_str);
279
+
let missing_ref = format!(
280
+
"Missing lexicon '{}' (referenced as '{}')",
281
+
target_lexicon, ref_str
282
+
);
240
283
if !missing_refs.contains(&missing_ref) {
241
284
missing_refs.push(missing_ref);
242
285
}
···
246
289
// Direct lexicon reference without fragment (e.g., app.bsky.richtext.facet)
247
290
// This references the main definition of another lexicon
248
291
if !self.lexicons.contains_key(ref_str) {
249
-
let missing_ref = format!("Missing lexicon '{}' (referenced as '{}')", ref_str, ref_str);
292
+
let missing_ref = format!(
293
+
"Missing lexicon '{}' (referenced as '{}')",
294
+
ref_str, ref_str
295
+
);
250
296
if !missing_refs.contains(&missing_ref) {
251
297
missing_refs.push(missing_ref);
252
298
}
···
254
300
}
255
301
}
256
302
}
257
-
303
+
258
304
// Check for refs array (used in union types)
259
305
if let Some(refs_array) = obj.get("refs").and_then(|v| v.as_array()) {
260
306
for ref_value in refs_array {
···
266
312
if parts.len() == 2 {
267
313
let target_lexicon = parts[0];
268
314
if !self.lexicons.contains_key(target_lexicon) {
269
-
let missing_ref = format!("Missing lexicon '{}' (referenced as '{}')", target_lexicon, ref_str);
315
+
let missing_ref = format!(
316
+
"Missing lexicon '{}' (referenced as '{}')",
317
+
target_lexicon, ref_str
318
+
);
270
319
if !missing_refs.contains(&missing_ref) {
271
320
missing_refs.push(missing_ref);
272
321
}
···
275
324
} else if ref_str.contains('.') {
276
325
// Direct lexicon reference
277
326
if !self.lexicons.contains_key(ref_str) {
278
-
let missing_ref = format!("Missing lexicon '{}' (referenced as '{}')", ref_str, ref_str);
327
+
let missing_ref = format!(
328
+
"Missing lexicon '{}' (referenced as '{}')",
329
+
ref_str, ref_str
330
+
);
279
331
if !missing_refs.contains(&missing_ref) {
280
332
missing_refs.push(missing_ref);
281
333
}
···
285
337
}
286
338
}
287
339
}
288
-
340
+
289
341
// Recursively check nested objects
290
342
for (_, nested_value) in obj {
291
343
self.collect_missing_references(current_lexicon, nested_value, missing_refs)?;
···
301
353
// Primitive values, nothing to check
302
354
}
303
355
}
304
-
356
+
305
357
Ok(())
306
358
}
307
-
359
+
308
360
/// Validate a value against a schema definition
309
-
fn validate_value(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
361
+
fn validate_value(
362
+
&self,
363
+
value: &Value,
364
+
schema: &Value,
365
+
ctx: &ValidationContext,
366
+
) -> Result<(), ValidationError> {
310
367
let schema_type = schema["type"].as_str().unwrap_or("");
311
-
368
+
312
369
match schema_type {
313
370
"object" => self.validate_object(value, schema, ctx),
314
371
"string" => self.validate_string(value, schema, ctx),
···
322
379
"cid-link" => self.validate_cid_link(value, schema, ctx),
323
380
"unknown" => Ok(()), // Unknown type accepts anything
324
381
"null" => self.validate_null(value, schema, ctx),
325
-
"" => Err(ValidationError::InvalidSchema(format!("Missing type in schema at {}", ctx.path_string()))),
326
-
_ => Err(ValidationError::InvalidSchema(format!("Unknown schema type: {} at {}", schema_type, ctx.path_string()))),
382
+
"" => Err(ValidationError::InvalidSchema(format!(
383
+
"Missing type in schema at {}",
384
+
ctx.path_string()
385
+
))),
386
+
_ => Err(ValidationError::InvalidSchema(format!(
387
+
"Unknown schema type: {} at {}",
388
+
schema_type,
389
+
ctx.path_string()
390
+
))),
327
391
}
328
392
}
329
-
393
+
330
394
/// Validate an object against an object schema
331
-
fn validate_object(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
332
-
let obj = value.as_object().ok_or_else(|| ValidationError::TypeMismatch {
333
-
path: ctx.path_string(),
334
-
expected: "object".to_string(),
335
-
actual: format!("{:?}", value),
336
-
})?;
337
-
395
+
fn validate_object(
396
+
&self,
397
+
value: &Value,
398
+
schema: &Value,
399
+
ctx: &ValidationContext,
400
+
) -> Result<(), ValidationError> {
401
+
let obj = value
402
+
.as_object()
403
+
.ok_or_else(|| ValidationError::TypeMismatch {
404
+
path: ctx.path_string(),
405
+
expected: "object".to_string(),
406
+
actual: format!("{:?}", value),
407
+
})?;
408
+
338
409
// Check required fields
339
410
if let Some(required) = schema["required"].as_array() {
340
411
for req_field in required {
···
347
418
}
348
419
}
349
420
}
350
-
421
+
351
422
// Validate properties
352
423
if let Some(properties) = schema["properties"].as_object() {
353
424
for (prop_name, prop_schema) in properties {
354
425
if let Some(prop_value) = obj.get(prop_name) {
355
426
// Check if field can be null
356
427
let nullable = if let Some(nullable_fields) = schema["nullable"].as_array() {
357
-
nullable_fields.iter().any(|f| f.as_str() == Some(prop_name))
428
+
nullable_fields
429
+
.iter()
430
+
.any(|f| f.as_str() == Some(prop_name))
358
431
} else {
359
432
false
360
433
};
361
-
434
+
362
435
if prop_value.is_null() && !nullable {
363
436
return Err(ValidationError::TypeMismatch {
364
437
path: ctx.with_field(prop_name).path_string(),
···
366
439
actual: "null".to_string(),
367
440
});
368
441
}
369
-
442
+
370
443
if !prop_value.is_null() {
371
444
self.validate_value(prop_value, prop_schema, &ctx.with_field(prop_name))?;
372
445
}
373
446
}
374
447
}
375
448
}
376
-
449
+
377
450
Ok(())
378
451
}
379
-
452
+
380
453
/// Validate a string value
381
-
fn validate_string(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
382
-
let string_val = value.as_str().ok_or_else(|| ValidationError::TypeMismatch {
383
-
path: ctx.path_string(),
384
-
expected: "string".to_string(),
385
-
actual: format!("{:?}", value),
386
-
})?;
387
-
454
+
fn validate_string(
455
+
&self,
456
+
value: &Value,
457
+
schema: &Value,
458
+
ctx: &ValidationContext,
459
+
) -> Result<(), ValidationError> {
460
+
let string_val = value
461
+
.as_str()
462
+
.ok_or_else(|| ValidationError::TypeMismatch {
463
+
path: ctx.path_string(),
464
+
expected: "string".to_string(),
465
+
actual: format!("{:?}", value),
466
+
})?;
467
+
388
468
// Check min/max length (in UTF-8 bytes)
389
469
if let Some(min_length) = schema["minLength"].as_u64() {
390
470
if (string_val.len() as u64) < min_length {
391
471
return Err(ValidationError::StringValidationFailed {
392
472
path: ctx.path_string(),
393
-
message: format!("String length {} is less than minimum {}", string_val.len(), min_length),
473
+
message: format!(
474
+
"String length {} is less than minimum {}",
475
+
string_val.len(),
476
+
min_length
477
+
),
394
478
});
395
479
}
396
480
}
397
-
481
+
398
482
if let Some(max_length) = schema["maxLength"].as_u64() {
399
483
if (string_val.len() as u64) > max_length {
400
484
return Err(ValidationError::StringValidationFailed {
401
485
path: ctx.path_string(),
402
-
message: format!("String length {} exceeds maximum {}", string_val.len(), max_length),
486
+
message: format!(
487
+
"String length {} exceeds maximum {}",
488
+
string_val.len(),
489
+
max_length
490
+
),
403
491
});
404
492
}
405
493
}
406
-
494
+
407
495
// Check min/max graphemes (simplified grapheme counting)
408
496
if let Some(min_graphemes) = schema["minGraphemes"].as_u64() {
409
497
let grapheme_count = self.count_graphemes(string_val);
410
498
if (grapheme_count as u64) < min_graphemes {
411
499
return Err(ValidationError::StringValidationFailed {
412
500
path: ctx.path_string(),
413
-
message: format!("String has {} graphemes, less than minimum {}", grapheme_count, min_graphemes),
501
+
message: format!(
502
+
"String has {} graphemes, less than minimum {}",
503
+
grapheme_count, min_graphemes
504
+
),
414
505
});
415
506
}
416
507
}
417
-
508
+
418
509
if let Some(max_graphemes) = schema["maxGraphemes"].as_u64() {
419
510
let grapheme_count = self.count_graphemes(string_val);
420
511
if (grapheme_count as u64) > max_graphemes {
421
512
return Err(ValidationError::StringValidationFailed {
422
513
path: ctx.path_string(),
423
-
message: format!("String has {} graphemes, exceeds maximum {}", grapheme_count, max_graphemes),
514
+
message: format!(
515
+
"String has {} graphemes, exceeds maximum {}",
516
+
grapheme_count, max_graphemes
517
+
),
424
518
});
425
519
}
426
520
}
427
-
521
+
428
522
// Check const value
429
523
if let Some(const_val) = schema["const"].as_str() {
430
524
if string_val != const_val {
···
435
529
});
436
530
}
437
531
}
438
-
532
+
439
533
// Check enum values
440
534
if let Some(enum_values) = schema["enum"].as_array() {
441
535
let valid = enum_values.iter().any(|v| v.as_str() == Some(string_val));
···
445
539
});
446
540
}
447
541
}
448
-
542
+
449
543
// Check format
450
544
if let Some(format_str) = schema["format"].as_str() {
451
545
if let Some(format) = StringFormat::from_str(format_str) {
452
546
self.validate_string_format(string_val, format, ctx)?;
453
547
}
454
548
}
455
-
549
+
456
550
Ok(())
457
551
}
458
-
552
+
459
553
/// Validate string formats
460
-
fn validate_string_format(&self, value: &str, format: StringFormat, ctx: &ValidationContext) -> Result<(), ValidationError> {
554
+
fn validate_string_format(
555
+
&self,
556
+
value: &str,
557
+
format: StringFormat,
558
+
ctx: &ValidationContext,
559
+
) -> Result<(), ValidationError> {
461
560
match format {
462
561
StringFormat::DateTime => {
463
562
// Validate RFC3339/ISO8601 datetime
464
-
DateTime::parse_from_rfc3339(value).map_err(|_| ValidationError::FormatValidationFailed {
465
-
path: ctx.path_string(),
466
-
format: "datetime".to_string(),
563
+
DateTime::parse_from_rfc3339(value).map_err(|_| {
564
+
ValidationError::FormatValidationFailed {
565
+
path: ctx.path_string(),
566
+
format: "datetime".to_string(),
567
+
}
467
568
})?;
468
-
},
569
+
}
469
570
StringFormat::Uri => {
470
571
// Basic URI validation - must have scheme followed by colon
471
572
// Valid schemes include http://, https://, urn:, did:, etc.
···
475
576
format: "uri".to_string(),
476
577
});
477
578
}
478
-
579
+
479
580
// Check that scheme contains only valid characters
480
581
let colon_pos = value.find(':').unwrap();
481
582
let scheme = &value[..colon_pos];
482
-
if scheme.is_empty() || !scheme.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') {
583
+
if scheme.is_empty()
584
+
|| !scheme
585
+
.chars()
586
+
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
587
+
{
483
588
return Err(ValidationError::FormatValidationFailed {
484
589
path: ctx.path_string(),
485
590
format: "uri".to_string(),
486
591
});
487
592
}
488
-
},
593
+
}
489
594
StringFormat::AtUri => {
490
595
// AT-URI format: at://[authority]/[collection]/[rkey]
491
596
if !value.starts_with("at://") {
···
494
599
format: "at-uri".to_string(),
495
600
});
496
601
}
497
-
},
602
+
}
498
603
StringFormat::Did => {
499
604
// DID format: did:method:identifier
500
605
if !value.starts_with("did:") {
···
503
608
format: "did".to_string(),
504
609
});
505
610
}
506
-
611
+
507
612
// Must have at least 3 parts: did:method:identifier
508
613
let parts: Vec<&str> = value.split(':').collect();
509
-
if parts.len() < 3 || parts[0] != "did" || parts[1].is_empty() || parts[2].is_empty() {
614
+
if parts.len() < 3
615
+
|| parts[0] != "did"
616
+
|| parts[1].is_empty()
617
+
|| parts[2].is_empty()
618
+
{
510
619
return Err(ValidationError::FormatValidationFailed {
511
620
path: ctx.path_string(),
512
621
format: "did".to_string(),
513
622
});
514
623
}
515
-
},
624
+
}
516
625
StringFormat::Handle => {
517
626
// Handle format: domain-like (e.g., user.bsky.social)
518
627
let handle_regex = Regex::new(r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$").unwrap();
···
522
631
format: "handle".to_string(),
523
632
});
524
633
}
525
-
},
634
+
}
526
635
StringFormat::AtIdentifier => {
527
636
// Either a DID or a handle (per spec: at-identifier can be either)
528
637
let is_did = value.starts_with("did:");
529
-
530
-
// Handle format: domain-like (e.g., user.bsky.social)
638
+
639
+
// Handle format: domain-like (e.g., user.bsky.social)
531
640
let handle_regex = Regex::new(r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$").unwrap();
532
641
let is_handle = handle_regex.is_match(value);
533
-
642
+
534
643
if !is_did && !is_handle {
535
644
return Err(ValidationError::FormatValidationFailed {
536
645
path: ctx.path_string(),
537
646
format: "at-identifier".to_string(),
538
647
});
539
648
}
540
-
},
649
+
}
541
650
StringFormat::Nsid => {
542
651
// NSID format: reversed domain with name (e.g., com.example.foo)
543
652
let nsid_regex = Regex::new(r"^[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$").unwrap();
···
547
656
format: "nsid".to_string(),
548
657
});
549
658
}
550
-
},
659
+
}
551
660
StringFormat::Cid => {
552
661
// Basic CID validation (starts with correct multibase prefix)
553
-
if !value.starts_with("bafy") && !value.starts_with("bafk") && !value.starts_with("b") {
662
+
if !value.starts_with("bafy")
663
+
&& !value.starts_with("bafk")
664
+
&& !value.starts_with("b")
665
+
{
554
666
return Err(ValidationError::FormatValidationFailed {
555
667
path: ctx.path_string(),
556
668
format: "cid".to_string(),
557
669
});
558
670
}
559
-
},
671
+
}
560
672
StringFormat::Tid => {
561
673
// TID format: timestamp-based identifier (13 chars, base32)
562
674
let tid_regex = Regex::new(r"^[234567abcdefghijklmnopqrstuvwxyz]{13}$").unwrap();
···
566
678
format: "tid".to_string(),
567
679
});
568
680
}
569
-
},
681
+
}
570
682
StringFormat::RecordKey => {
571
683
// Record key: alphanumeric, dash, underscore, colon, tilde, or TID
572
684
let rkey_regex = Regex::new(r"^[a-zA-Z0-9._:~-]+$").unwrap();
···
576
688
format: "record-key".to_string(),
577
689
});
578
690
}
579
-
},
691
+
}
580
692
StringFormat::Language => {
581
693
// BCP47 language tag (simplified validation)
582
694
// Allows for language-region-extension pattern like "en-US-boont"
···
587
699
format: "language".to_string(),
588
700
});
589
701
}
590
-
},
702
+
}
591
703
}
592
-
704
+
593
705
Ok(())
594
706
}
595
-
707
+
596
708
/// Validate an integer value
597
-
fn validate_integer(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
598
-
let int_val = value.as_i64().ok_or_else(|| ValidationError::TypeMismatch {
599
-
path: ctx.path_string(),
600
-
expected: "integer".to_string(),
601
-
actual: format!("{:?}", value),
602
-
})?;
603
-
709
+
fn validate_integer(
710
+
&self,
711
+
value: &Value,
712
+
schema: &Value,
713
+
ctx: &ValidationContext,
714
+
) -> Result<(), ValidationError> {
715
+
let int_val = value
716
+
.as_i64()
717
+
.ok_or_else(|| ValidationError::TypeMismatch {
718
+
path: ctx.path_string(),
719
+
expected: "integer".to_string(),
720
+
actual: format!("{:?}", value),
721
+
})?;
722
+
604
723
if let Some(minimum) = schema["minimum"].as_i64() {
605
724
if int_val < minimum {
606
725
return Err(ValidationError::IntegerValidationFailed {
···
609
728
});
610
729
}
611
730
}
612
-
731
+
613
732
if let Some(maximum) = schema["maximum"].as_i64() {
614
733
if int_val > maximum {
615
734
return Err(ValidationError::IntegerValidationFailed {
···
618
737
});
619
738
}
620
739
}
621
-
740
+
622
741
// Check const value
623
742
if let Some(const_val) = schema["const"].as_i64() {
624
743
if int_val != const_val {
···
629
748
});
630
749
}
631
750
}
632
-
751
+
633
752
if let Some(enum_values) = schema["enum"].as_array() {
634
753
let valid = enum_values.iter().any(|v| v.as_i64() == Some(int_val));
635
754
if !valid {
···
638
757
});
639
758
}
640
759
}
641
-
760
+
642
761
Ok(())
643
762
}
644
-
763
+
645
764
/// Validate a boolean value
646
-
fn validate_boolean(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
647
-
value.as_bool().ok_or_else(|| ValidationError::TypeMismatch {
648
-
path: ctx.path_string(),
649
-
expected: "boolean".to_string(),
650
-
actual: format!("{:?}", value),
651
-
})?;
652
-
765
+
fn validate_boolean(
766
+
&self,
767
+
value: &Value,
768
+
schema: &Value,
769
+
ctx: &ValidationContext,
770
+
) -> Result<(), ValidationError> {
771
+
value
772
+
.as_bool()
773
+
.ok_or_else(|| ValidationError::TypeMismatch {
774
+
path: ctx.path_string(),
775
+
expected: "boolean".to_string(),
776
+
actual: format!("{:?}", value),
777
+
})?;
778
+
653
779
// Check const value if specified
654
780
if let Some(const_val) = schema["const"].as_bool() {
655
781
if value.as_bool() != Some(const_val) {
···
660
786
});
661
787
}
662
788
}
663
-
789
+
664
790
Ok(())
665
791
}
666
-
792
+
667
793
/// Validate an array
668
-
fn validate_array(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
669
-
let array = value.as_array().ok_or_else(|| ValidationError::TypeMismatch {
670
-
path: ctx.path_string(),
671
-
expected: "array".to_string(),
672
-
actual: format!("{:?}", value),
673
-
})?;
674
-
794
+
fn validate_array(
795
+
&self,
796
+
value: &Value,
797
+
schema: &Value,
798
+
ctx: &ValidationContext,
799
+
) -> Result<(), ValidationError> {
800
+
let array = value
801
+
.as_array()
802
+
.ok_or_else(|| ValidationError::TypeMismatch {
803
+
path: ctx.path_string(),
804
+
expected: "array".to_string(),
805
+
actual: format!("{:?}", value),
806
+
})?;
807
+
675
808
// Check min/max length
676
809
if let Some(min_length) = schema["minLength"].as_u64() {
677
810
if (array.len() as u64) < min_length {
678
811
return Err(ValidationError::ArrayValidationFailed {
679
812
path: ctx.path_string(),
680
-
message: format!("Array length {} is less than minimum {}", array.len(), min_length),
813
+
message: format!(
814
+
"Array length {} is less than minimum {}",
815
+
array.len(),
816
+
min_length
817
+
),
681
818
});
682
819
}
683
820
}
684
-
821
+
685
822
if let Some(max_length) = schema["maxLength"].as_u64() {
686
823
if (array.len() as u64) > max_length {
687
824
return Err(ValidationError::ArrayValidationFailed {
688
825
path: ctx.path_string(),
689
-
message: format!("Array length {} exceeds maximum {}", array.len(), max_length),
826
+
message: format!(
827
+
"Array length {} exceeds maximum {}",
828
+
array.len(),
829
+
max_length
830
+
),
690
831
});
691
832
}
692
833
}
693
-
834
+
694
835
// Validate items
695
836
if let Some(items_schema) = schema.get("items") {
696
837
for (i, item) in array.iter().enumerate() {
697
838
self.validate_value(item, items_schema, &ctx.with_index(i))?;
698
839
}
699
840
}
700
-
841
+
701
842
Ok(())
702
843
}
703
-
844
+
704
845
/// Validate a reference
705
-
fn validate_ref(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
846
+
fn validate_ref(
847
+
&self,
848
+
value: &Value,
849
+
schema: &Value,
850
+
ctx: &ValidationContext,
851
+
) -> Result<(), ValidationError> {
706
852
let ref_path = schema["ref"].as_str().ok_or_else(|| {
707
853
ValidationError::InvalidSchema(format!("Missing ref path at {}", ctx.path_string()))
708
854
})?;
709
-
855
+
710
856
// Resolve the reference
711
857
let resolved_schema = self.resolve_ref(ref_path)?;
712
-
858
+
713
859
// Validate against the resolved schema
714
860
self.validate_value(value, &resolved_schema, ctx)
715
861
}
716
-
862
+
717
863
/// Resolve a reference to its schema
718
864
fn resolve_ref(&self, ref_path: &str) -> Result<Value, ValidationError> {
719
865
// Note: Local references need context of which lexicon we're in
···
723
869
// Local reference - would need current lexicon context to resolve
724
870
// For comprehensive testing, we'll implement a basic version
725
871
let fragment = &ref_path[1..]; // Remove the # prefix
726
-
872
+
727
873
// Try to find this fragment in any loaded lexicon (simplified approach)
728
874
for lexicon in self.lexicons.values() {
729
875
let def = &lexicon.defs[fragment];
···
731
877
return Ok(def.clone());
732
878
}
733
879
}
734
-
880
+
735
881
return Err(ValidationError::ReferenceNotFound(ref_path.to_string()));
736
882
}
737
-
883
+
738
884
// Parse NSID#fragment format
739
885
let parts: Vec<&str> = ref_path.split('#').collect();
740
886
let nsid = parts[0];
741
887
let fragment = parts.get(1).map(|s| *s).unwrap_or("main");
742
-
743
-
let lexicon = self.lexicons.get(nsid)
888
+
889
+
let lexicon = self
890
+
.lexicons
891
+
.get(nsid)
744
892
.ok_or_else(|| ValidationError::ReferenceNotFound(nsid.to_string()))?;
745
-
893
+
746
894
let def = &lexicon.defs[fragment];
747
895
if def.is_null() {
748
896
return Err(ValidationError::ReferenceNotFound(ref_path.to_string()));
749
897
}
750
-
898
+
751
899
Ok(def.clone())
752
900
}
753
-
901
+
754
902
/// Validate a union type
755
-
fn validate_union(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
903
+
fn validate_union(
904
+
&self,
905
+
value: &Value,
906
+
schema: &Value,
907
+
ctx: &ValidationContext,
908
+
) -> Result<(), ValidationError> {
756
909
let refs = schema["refs"].as_array().ok_or_else(|| {
757
910
ValidationError::InvalidSchema(format!("Union missing refs at {}", ctx.path_string()))
758
911
})?;
759
-
912
+
760
913
let is_closed = schema["closed"].as_bool().unwrap_or(false);
761
-
914
+
762
915
// Check if value has $type field
763
916
if let Some(type_field) = value.get("$type").and_then(|v| v.as_str()) {
764
917
// Try to match against the specific type
765
918
for ref_value in refs {
766
919
if let Some(ref_str) = ref_value.as_str() {
767
920
// For exact type match, validate against the reference
768
-
if ref_str == type_field || ref_str.split('#').next().unwrap_or(ref_str) == type_field {
921
+
if ref_str == type_field
922
+
|| ref_str.split('#').next().unwrap_or(ref_str) == type_field
923
+
{
769
924
return self.validate_ref(value, &serde_json::json!({"ref": ref_str}), ctx);
770
925
}
771
926
}
772
927
}
773
-
928
+
774
929
// If this is an open union and we have a $type field, allow unknown types
775
930
if !is_closed {
776
931
// For open unions, any object with a $type is valid as long as it's structured properly
···
779
934
}
780
935
}
781
936
}
782
-
937
+
783
938
// Try each variant (fallback for objects without $type or when $type doesn't match)
784
939
for ref_value in refs {
785
940
if let Some(ref_str) = ref_value.as_str() {
···
789
944
}
790
945
}
791
946
}
792
-
947
+
793
948
Err(ValidationError::UnionValidationFailed {
794
949
path: ctx.path_string(),
795
950
})
796
951
}
797
-
952
+
798
953
/// Validate a blob reference
799
-
fn validate_blob(&self, value: &Value, _schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
800
-
let obj = value.as_object().ok_or_else(|| ValidationError::TypeMismatch {
801
-
path: ctx.path_string(),
802
-
expected: "blob object".to_string(),
803
-
actual: format!("{:?}", value),
804
-
})?;
805
-
954
+
fn validate_blob(
955
+
&self,
956
+
value: &Value,
957
+
_schema: &Value,
958
+
ctx: &ValidationContext,
959
+
) -> Result<(), ValidationError> {
960
+
let obj = value
961
+
.as_object()
962
+
.ok_or_else(|| ValidationError::TypeMismatch {
963
+
path: ctx.path_string(),
964
+
expected: "blob object".to_string(),
965
+
actual: format!("{:?}", value),
966
+
})?;
967
+
806
968
// Check required blob fields
807
969
if !obj.contains_key("$type") || obj["$type"] != "blob" {
808
970
return Err(ValidationError::TypeMismatch {
···
811
973
actual: format!("{:?}", value),
812
974
});
813
975
}
814
-
976
+
815
977
// Check for ref object with $link
816
978
let ref_obj = obj.get("ref").and_then(|v| v.as_object()).ok_or_else(|| {
817
979
ValidationError::TypeMismatch {
···
820
982
actual: "missing or invalid".to_string(),
821
983
}
822
984
})?;
823
-
985
+
824
986
if !ref_obj.contains_key("$link") {
825
987
return Err(ValidationError::TypeMismatch {
826
988
path: ctx.with_field("ref.$link").path_string(),
···
828
990
actual: "missing".to_string(),
829
991
});
830
992
}
831
-
993
+
832
994
// Check other required fields
833
995
if !obj.contains_key("mimeType") {
834
996
return Err(ValidationError::RequiredFieldMissing {
835
997
path: ctx.with_field("mimeType").path_string(),
836
998
});
837
999
}
838
-
1000
+
839
1001
if !obj.contains_key("size") {
840
1002
return Err(ValidationError::RequiredFieldMissing {
841
1003
path: ctx.with_field("size").path_string(),
842
1004
});
843
1005
}
844
-
1006
+
845
1007
Ok(())
846
1008
}
847
-
1009
+
848
1010
/// Validate bytes type
849
-
fn validate_bytes(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
1011
+
fn validate_bytes(
1012
+
&self,
1013
+
value: &Value,
1014
+
schema: &Value,
1015
+
ctx: &ValidationContext,
1016
+
) -> Result<(), ValidationError> {
850
1017
// Bytes in JSON are typically base64 encoded strings
851
-
let string_val = value.as_str().ok_or_else(|| ValidationError::TypeMismatch {
852
-
path: ctx.path_string(),
853
-
expected: "bytes (base64 string)".to_string(),
854
-
actual: format!("{:?}", value),
855
-
})?;
856
-
1018
+
let string_val = value
1019
+
.as_str()
1020
+
.ok_or_else(|| ValidationError::TypeMismatch {
1021
+
path: ctx.path_string(),
1022
+
expected: "bytes (base64 string)".to_string(),
1023
+
actual: format!("{:?}", value),
1024
+
})?;
1025
+
857
1026
// Basic base64 validation (simplified)
858
-
if !string_val.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') {
1027
+
if !string_val
1028
+
.chars()
1029
+
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=')
1030
+
{
859
1031
return Err(ValidationError::TypeMismatch {
860
1032
path: ctx.path_string(),
861
1033
expected: "valid base64 string".to_string(),
862
1034
actual: "invalid base64".to_string(),
863
1035
});
864
1036
}
865
-
1037
+
866
1038
// Check length constraints (bytes length, not string length)
867
-
let decoded_len = (string_val.len() * 3 / 4) - string_val.chars().filter(|&c| c == '=').count();
868
-
1039
+
let decoded_len =
1040
+
(string_val.len() * 3 / 4) - string_val.chars().filter(|&c| c == '=').count();
1041
+
869
1042
if let Some(min_length) = schema["minLength"].as_u64() {
870
1043
if (decoded_len as u64) < min_length {
871
1044
return Err(ValidationError::StringValidationFailed {
872
1045
path: ctx.path_string(),
873
-
message: format!("Bytes length {} is less than minimum {}", decoded_len, min_length),
1046
+
message: format!(
1047
+
"Bytes length {} is less than minimum {}",
1048
+
decoded_len, min_length
1049
+
),
874
1050
});
875
1051
}
876
1052
}
877
-
1053
+
878
1054
if let Some(max_length) = schema["maxLength"].as_u64() {
879
1055
if (decoded_len as u64) > max_length {
880
1056
return Err(ValidationError::StringValidationFailed {
881
1057
path: ctx.path_string(),
882
-
message: format!("Bytes length {} exceeds maximum {}", decoded_len, max_length),
1058
+
message: format!(
1059
+
"Bytes length {} exceeds maximum {}",
1060
+
decoded_len, max_length
1061
+
),
883
1062
});
884
1063
}
885
1064
}
886
-
1065
+
887
1066
Ok(())
888
1067
}
889
-
1068
+
890
1069
/// Validate cid-link type
891
-
fn validate_cid_link(&self, value: &Value, _schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
892
-
let obj = value.as_object().ok_or_else(|| ValidationError::TypeMismatch {
893
-
path: ctx.path_string(),
894
-
expected: "cid-link object".to_string(),
895
-
actual: format!("{:?}", value),
896
-
})?;
897
-
1070
+
fn validate_cid_link(
1071
+
&self,
1072
+
value: &Value,
1073
+
_schema: &Value,
1074
+
ctx: &ValidationContext,
1075
+
) -> Result<(), ValidationError> {
1076
+
let obj = value
1077
+
.as_object()
1078
+
.ok_or_else(|| ValidationError::TypeMismatch {
1079
+
path: ctx.path_string(),
1080
+
expected: "cid-link object".to_string(),
1081
+
actual: format!("{:?}", value),
1082
+
})?;
1083
+
898
1084
// CID-link must have $link field with a valid CID
899
1085
let link = obj.get("$link").and_then(|v| v.as_str()).ok_or_else(|| {
900
1086
ValidationError::RequiredFieldMissing {
901
1087
path: ctx.with_field("$link").path_string(),
902
1088
}
903
1089
})?;
904
-
1090
+
905
1091
// Basic CID validation (simplified - should start with appropriate multibase prefix)
906
-
if !link.starts_with("bafy") && !link.starts_with("bafk") && !link.starts_with("b") && link.len() < 20 {
1092
+
if !link.starts_with("bafy")
1093
+
&& !link.starts_with("bafk")
1094
+
&& !link.starts_with("b")
1095
+
&& link.len() < 20
1096
+
{
907
1097
return Err(ValidationError::FormatValidationFailed {
908
1098
path: ctx.with_field("$link").path_string(),
909
1099
format: "cid".to_string(),
910
1100
});
911
1101
}
912
-
1102
+
913
1103
Ok(())
914
1104
}
915
-
1105
+
916
1106
/// Validate null type
917
-
fn validate_null(&self, value: &Value, _schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> {
1107
+
fn validate_null(
1108
+
&self,
1109
+
value: &Value,
1110
+
_schema: &Value,
1111
+
ctx: &ValidationContext,
1112
+
) -> Result<(), ValidationError> {
918
1113
if !value.is_null() {
919
1114
return Err(ValidationError::TypeMismatch {
920
1115
path: ctx.path_string(),
···
924
1119
}
925
1120
Ok(())
926
1121
}
927
-
1122
+
928
1123
/// Simplified grapheme counting (approximation)
929
-
/// This is a basic implementation - for full compliance with TS version,
1124
+
/// This is a basic implementation - for full compliance with TS version,
930
1125
/// would need proper Unicode grapheme cluster segmentation
931
1126
fn count_graphemes(&self, s: &str) -> usize {
932
1127
// Very simplified approach: count Unicode scalar values, with basic combining character handling
933
1128
let mut count = 0;
934
1129
let mut chars = s.chars().peekable();
935
-
1130
+
936
1131
while let Some(_ch) = chars.next() {
937
1132
count += 1;
938
-
1133
+
939
1134
// Skip combining characters that follow this base character
940
1135
while let Some(&next_ch) = chars.peek() {
941
1136
if self.is_combining_character(next_ch) {
···
945
1140
}
946
1141
}
947
1142
}
948
-
1143
+
949
1144
count
950
1145
}
951
-
1146
+
952
1147
/// Check if a character is a combining character (very simplified)
953
1148
fn is_combining_character(&self, ch: char) -> bool {
954
1149
// This is a simplified check for common combining marks
955
1150
// In a full implementation, would need proper Unicode category checking
956
-
matches!(ch as u32,
1151
+
matches!(ch as u32,
957
1152
0x0300..=0x036F | // Combining Diacritical Marks
958
1153
0x1AB0..=0x1AFF | // Combining Diacritical Marks Extended
959
1154
0x1DC0..=0x1DFF | // Combining Diacritical Marks Supplement
···
961
1156
0xFE20..=0xFE2F // Combining Half Marks
962
1157
)
963
1158
}
964
-
}
1159
+
}
+31
-24
api/src/main.rs
+31
-24
api/src/main.rs
···
38
38
use crate::database::Database;
39
39
use crate::errors::AppError;
40
40
use crate::jetstream::JetstreamConsumer;
41
-
use crate::logging::{Logger, LogLevel};
41
+
use crate::logging::{LogLevel, Logger};
42
42
43
43
#[derive(Clone)]
44
44
pub struct Config {
···
158
158
Logger::global().log_jetstream(
159
159
LogLevel::Info,
160
160
"Starting Jetstream consumer",
161
-
Some(serde_json::json!({"action": "starting_consumer"}))
161
+
Some(serde_json::json!({"action": "starting_consumer"})),
162
162
);
163
163
164
164
// Use existing consumer or create new one
···
173
173
{
174
174
Ok(consumer) => std::sync::Arc::new(consumer),
175
175
Err(e) => {
176
-
let message = format!("Failed to create Jetstream consumer: {} - will retry in {:?}", e, retry_delay);
176
+
let message = format!(
177
+
"Failed to create Jetstream consumer: {} - will retry in {:?}",
178
+
e, retry_delay
179
+
);
177
180
tracing::error!("{}", message);
178
181
Logger::global().log_jetstream(
179
182
LogLevel::Error,
···
182
185
"error": e.to_string(),
183
186
"retry_delay_secs": retry_delay.as_secs(),
184
187
"action": "consumer_creation_failed"
185
-
}))
188
+
})),
186
189
);
187
190
jetstream_connected_clone
188
191
.store(false, std::sync::atomic::Ordering::Relaxed);
···
221
224
// No events for 60+ seconds - mark as disconnected
222
225
health_check_connected
223
226
.store(false, std::sync::atomic::Ordering::Relaxed);
224
-
let message = format!("Jetstream marked as disconnected: no events processed in {} seconds", no_events_duration);
227
+
let message = format!(
228
+
"Jetstream marked as disconnected: no events processed in {} seconds",
229
+
no_events_duration
230
+
);
225
231
tracing::warn!("{}", message);
226
232
Logger::global().log_jetstream(
227
233
LogLevel::Warn,
···
229
235
Some(serde_json::json!({
230
236
"no_events_duration_secs": no_events_duration,
231
237
"action": "health_check_disconnected"
232
-
}))
238
+
})),
233
239
);
234
240
}
235
241
} else {
···
238
244
health_check_connected
239
245
.store(true, std::sync::atomic::Ordering::Relaxed);
240
246
if last_count == 0 && current_count > 0 {
241
-
let message = "Jetstream health check: events flowing, marked as connected";
247
+
let message =
248
+
"Jetstream health check: events flowing, marked as connected";
242
249
tracing::info!("{}", message);
243
250
Logger::global().log_jetstream(
244
251
LogLevel::Info,
···
246
253
Some(serde_json::json!({
247
254
"event_count": current_count,
248
255
"action": "health_check_connected"
249
-
}))
256
+
})),
250
257
);
251
258
}
252
259
}
···
313
320
)
314
321
// XRPC endpoints
315
322
.route(
316
-
"/xrpc/social.slices.slice.startSync",
323
+
"/xrpc/network.slices.slice.startSync",
317
324
post(handler_sync::sync),
318
325
)
319
326
.route(
320
-
"/xrpc/social.slices.slice.syncUserCollections",
327
+
"/xrpc/network.slices.slice.syncUserCollections",
321
328
post(handler_sync_user_collections::sync_user_collections),
322
329
)
323
330
.route(
324
-
"/xrpc/social.slices.slice.getJobStatus",
331
+
"/xrpc/network.slices.slice.getJobStatus",
325
332
get(handler_jobs::get_job_status),
326
333
)
327
334
.route(
328
-
"/xrpc/social.slices.slice.getJobHistory",
335
+
"/xrpc/network.slices.slice.getJobHistory",
329
336
get(handler_jobs::get_slice_job_history),
330
337
)
331
338
.route(
332
-
"/xrpc/social.slices.slice.getJobLogs",
339
+
"/xrpc/network.slices.slice.getJobLogs",
333
340
get(handler_logs::get_sync_job_logs_handler),
334
341
)
335
342
.route(
336
-
"/xrpc/social.slices.slice.getJetstreamLogs",
343
+
"/xrpc/network.slices.slice.getJetstreamLogs",
337
344
get(handler_logs::get_jetstream_logs_handler),
338
345
)
339
346
.route(
340
-
"/xrpc/social.slices.slice.stats",
347
+
"/xrpc/network.slices.slice.stats",
341
348
post(handler_stats::stats),
342
349
)
343
350
.route(
344
-
"/xrpc/social.slices.slice.getSliceRecords",
351
+
"/xrpc/network.slices.slice.getSliceRecords",
345
352
post(handler_get_records::get_records),
346
353
)
347
354
.route(
348
-
"/xrpc/social.slices.slice.codegen",
355
+
"/xrpc/network.slices.slice.codegen",
349
356
post(handler_xrpc_codegen::generate_client_xrpc),
350
357
)
351
358
.route(
352
-
"/xrpc/social.slices.slice.openapi",
359
+
"/xrpc/network.slices.slice.openapi",
353
360
get(handler_openapi_spec::get_openapi_spec),
354
361
)
355
362
.route(
356
-
"/xrpc/social.slices.slice.getJetstreamStatus",
363
+
"/xrpc/network.slices.slice.getJetstreamStatus",
357
364
get(handler_jetstream_status::get_jetstream_status),
358
365
)
359
366
.route(
360
-
"/xrpc/social.slices.slice.getActors",
367
+
"/xrpc/network.slices.slice.getActors",
361
368
post(handler_get_actors::get_actors),
362
369
)
363
370
// OAuth client management endpoints
364
371
.route(
365
-
"/xrpc/social.slices.slice.createOAuthClient",
372
+
"/xrpc/network.slices.slice.createOAuthClient",
366
373
post(handler_oauth_clients::create_oauth_client),
367
374
)
368
375
.route(
369
-
"/xrpc/social.slices.slice.getOAuthClients",
376
+
"/xrpc/network.slices.slice.getOAuthClients",
370
377
get(handler_oauth_clients::get_oauth_clients),
371
378
)
372
379
.route(
373
-
"/xrpc/social.slices.slice.updateOAuthClient",
380
+
"/xrpc/network.slices.slice.updateOAuthClient",
374
381
post(handler_oauth_clients::update_oauth_client),
375
382
)
376
383
.route(
377
-
"/xrpc/social.slices.slice.deleteOAuthClient",
384
+
"/xrpc/network.slices.slice.deleteOAuthClient",
378
385
post(handler_oauth_clients::delete_oauth_client),
379
386
)
380
387
// Dynamic collection-specific XRPC endpoints (wildcard routes must come last)
+57
-24
docs/api-reference.md
+57
-24
docs/api-reference.md
···
10
10
11
11
## Authentication
12
12
13
-
Most write operations require OAuth 2.0 authentication. Include the access token in the Authorization header:
13
+
Most write operations require OAuth 2.0 authentication. Include the access token
14
+
in the Authorization header:
14
15
15
16
```
16
17
Authorization: Bearer YOUR_ACCESS_TOKEN
···
22
23
23
24
### Slice Management
24
25
25
-
#### `social.slices.slice.listRecords`
26
+
#### `network.slices.slice.listRecords`
26
27
27
28
List all slices.
28
29
29
30
**Method**: GET
30
31
31
32
**Parameters**:
33
+
32
34
- `limit` (number, optional): Maximum records to return (default: 50)
33
35
- `cursor` (string, optional): Pagination cursor
34
36
- `sort` (string, optional): Sort field and order (e.g., `createdAt:desc`)
···
36
38
- `authors` (string[], optional): Filter by multiple author DIDs
37
39
38
40
**Response**:
41
+
39
42
```json
40
43
{
41
44
"records": [
42
45
{
43
-
"uri": "at://did:plc:abc/social.slices.slice/xyz",
46
+
"uri": "at://did:plc:abc/network.slices.slice/xyz",
44
47
"cid": "bafyrei...",
45
48
"did": "did:plc:abc",
46
-
"collection": "social.slices.slice",
49
+
"collection": "network.slices.slice",
47
50
"value": {
48
51
"name": "My Slice",
49
52
"domain": "com.example",
···
56
59
}
57
60
```
58
61
59
-
#### `social.slices.slice.getRecord`
62
+
#### `network.slices.slice.getRecord`
60
63
61
64
Get a specific slice by URI.
62
65
63
66
**Method**: GET
64
67
65
68
**Parameters**:
69
+
66
70
- `uri` (string, required): AT Protocol URI of the slice
67
71
68
72
**Response**: Single record object (same structure as listRecords item)
69
73
70
-
#### `social.slices.slice.createRecord`
74
+
#### `network.slices.slice.createRecord`
71
75
72
76
Create a new slice.
73
77
···
76
80
**Authentication**: Required
77
81
78
82
**Body**:
83
+
79
84
```json
80
85
{
81
86
"slice": "at://your-slice-uri",
82
87
"record": {
83
-
"$type": "social.slices.slice",
88
+
"$type": "network.slices.slice",
84
89
"name": "My New Slice",
85
90
"domain": "com.example",
86
91
"createdAt": "2024-01-01T00:00:00Z"
···
90
95
```
91
96
92
97
**Response**:
98
+
93
99
```json
94
100
{
95
-
"uri": "at://did:plc:abc/social.slices.slice/xyz",
101
+
"uri": "at://did:plc:abc/network.slices.slice/xyz",
96
102
"cid": "bafyrei..."
97
103
}
98
104
```
99
105
100
106
### Slice Operations
101
107
102
-
#### `social.slices.slice.stats`
108
+
#### `network.slices.slice.stats`
103
109
104
110
Get statistics for a slice.
105
111
106
112
**Method**: POST
107
113
108
114
**Body**:
115
+
109
116
```json
110
117
{
111
118
"slice": "at://your-slice-uri"
···
113
120
```
114
121
115
122
**Response**:
123
+
116
124
```json
117
125
{
118
126
"success": true,
···
131
139
}
132
140
```
133
141
134
-
#### `social.slices.slice.listSliceRecords`
142
+
#### `network.slices.slice.listSliceRecords`
135
143
136
144
List records across multiple collections in a slice.
137
145
138
146
**Method**: POST
139
147
140
148
**Body**:
149
+
141
150
```json
142
151
{
143
152
"slice": "at://your-slice-uri",
···
149
158
```
150
159
151
160
**Response**:
161
+
152
162
```json
153
163
{
154
164
"success": true,
···
158
168
"cid": "bafyrei...",
159
169
"did": "did:plc:abc",
160
170
"collection": "com.example.post",
161
-
"value": { /* record data */ },
171
+
"value": {/* record data */},
162
172
"indexedAt": "2024-01-01T00:00:00Z"
163
173
}
164
174
],
···
166
176
}
167
177
```
168
178
169
-
#### `social.slices.slice.searchSliceRecords`
179
+
#### `network.slices.slice.searchSliceRecords`
170
180
171
181
Search records across multiple collections in a slice by content.
172
182
173
183
**Method**: POST
174
184
175
185
**Body**:
186
+
176
187
```json
177
188
{
178
189
"slice": "at://your-slice-uri",
···
185
196
```
186
197
187
198
**Response**:
199
+
188
200
```json
189
201
{
190
202
"success": true,
···
194
206
"cid": "bafyrei...",
195
207
"did": "did:plc:abc",
196
208
"collection": "com.example.post",
197
-
"value": { /* record data */ },
209
+
"value": {/* record data */},
198
210
"indexedAt": "2024-01-01T00:00:00Z"
199
211
}
200
212
],
···
202
214
}
203
215
```
204
216
205
-
#### `social.slices.slice.syncUserCollections`
217
+
#### `network.slices.slice.syncUserCollections`
206
218
207
219
Synchronously sync collections for the authenticated user.
208
220
···
211
223
**Authentication**: Required
212
224
213
225
**Body**:
226
+
214
227
```json
215
228
{
216
229
"slice": "at://your-slice-uri",
···
219
232
```
220
233
221
234
**Response**:
235
+
222
236
```json
223
237
{
224
238
"success": true,
···
229
243
}
230
244
```
231
245
232
-
#### `social.slices.slice.startSync`
246
+
#### `network.slices.slice.startSync`
233
247
234
248
Start an asynchronous bulk sync job.
235
249
···
238
252
**Authentication**: Required
239
253
240
254
**Body**:
255
+
241
256
```json
242
257
{
243
258
"slice": "at://your-slice-uri",
···
249
264
```
250
265
251
266
**Response**:
267
+
252
268
```json
253
269
{
254
270
"success": true,
···
257
273
}
258
274
```
259
275
260
-
#### `social.slices.slice.codegen`
276
+
#### `network.slices.slice.codegen`
261
277
262
278
Generate TypeScript client code.
263
279
264
280
**Method**: POST
265
281
266
282
**Body**:
283
+
267
284
```json
268
285
{
269
286
"target": "typescript",
···
272
289
```
273
290
274
291
**Response**:
292
+
275
293
```json
276
294
{
277
295
"success": true,
···
281
299
282
300
## Dynamic Collection Endpoints
283
301
284
-
For each collection in your slice, the following endpoints are automatically generated:
302
+
For each collection in your slice, the following endpoints are automatically
303
+
generated:
285
304
286
305
### `[collection].listRecords`
287
306
···
290
309
**Method**: GET
291
310
292
311
**Parameters**:
312
+
293
313
- `slice` (string, required): Slice URI
294
314
- `limit` (number, optional): Maximum records (default: 50)
295
315
- `cursor` (string, optional): Pagination cursor
···
304
324
**Method**: GET
305
325
306
326
**Parameters**:
327
+
307
328
- `slice` (string, required): Slice URI
308
329
- `uri` (string, required): Record URI
309
330
···
314
335
**Method**: GET
315
336
316
337
**Parameters**:
338
+
317
339
- `slice` (string, required): Slice URI
318
340
- `query` (string, required): Search query
319
341
- `field` (string, optional): Specific field to search
···
330
352
**Authentication**: Required
331
353
332
354
**Body**:
355
+
333
356
```json
334
357
{
335
358
"slice": "at://your-slice-uri",
336
359
"record": {
337
-
"$type": "collection.name",
360
+
"$type": "collection.name"
338
361
/* record fields */
339
362
},
340
363
"rkey": "optional-key"
···
350
373
**Authentication**: Required
351
374
352
375
**Body**:
376
+
353
377
```json
354
378
{
355
379
"slice": "at://your-slice-uri",
356
380
"rkey": "record-key",
357
381
"record": {
358
-
"$type": "collection.name",
382
+
"$type": "collection.name"
359
383
/* updated fields */
360
384
}
361
385
}
···
370
394
**Authentication**: Required
371
395
372
396
**Body**:
397
+
373
398
```json
374
399
{
375
400
"rkey": "record-key"
···
378
403
379
404
## Lexicon Management
380
405
381
-
### `social.slices.lexicon.listRecords`
406
+
### `network.slices.lexicon.listRecords`
382
407
383
408
List lexicons in a slice.
384
409
···
386
411
387
412
**Parameters**: Same as collection.listRecords
388
413
389
-
### `social.slices.lexicon.createRecord`
414
+
### `network.slices.lexicon.createRecord`
390
415
391
416
Add a lexicon to a slice.
392
417
···
395
420
**Authentication**: Required
396
421
397
422
**Body**:
423
+
398
424
```json
399
425
{
400
426
"slice": "at://your-slice-uri",
401
427
"record": {
402
-
"$type": "social.slices.lexicon",
428
+
"$type": "network.slices.lexicon",
403
429
"nsid": "com.example.post",
404
430
"definitions": "{\"lexicon\": 1, ...}",
405
431
"createdAt": "2024-01-01T00:00:00Z",
···
410
436
411
437
## Actor Management
412
438
413
-
### `social.slices.slice.getActors`
439
+
### `network.slices.slice.getActors`
414
440
415
441
Get actors (users) in a slice.
416
442
417
443
**Method**: GET
418
444
419
445
**Parameters**:
446
+
420
447
- `slice` (string, required): Slice URI
421
448
- `search` (string, optional): Search query
422
449
- `dids` (string[], optional): Filter by DIDs
···
424
451
- `cursor` (string, optional): Pagination cursor
425
452
426
453
**Response**:
454
+
427
455
```json
428
456
{
429
457
"actors": [
···
449
477
**Authentication**: Required
450
478
451
479
**Headers**:
480
+
452
481
- `Content-Type`: MIME type of the blob
453
482
454
483
**Body**: Raw binary data
455
484
456
485
**Response**:
486
+
457
487
```json
458
488
{
459
489
"blob": {
···
477
507
```
478
508
479
509
Common HTTP status codes:
510
+
480
511
- `200`: Success
481
512
- `400`: Bad request
482
513
- `401`: Authentication required
···
493
524
3. Continue until no cursor returned
494
525
495
526
Example:
527
+
496
528
```javascript
497
529
let cursor = undefined;
498
530
do {
···
508
540
Sort parameter format: `field:order` or `field1:order1,field2:order2`
509
541
510
542
Examples:
543
+
511
544
- `createdAt:desc` - Newest first
512
545
- `name:asc` - Alphabetical
513
546
- `createdAt:desc,name:asc` - Newest first, then alphabetical
···
516
549
517
550
- [SDK Usage](./sdk-usage.md) - Using generated TypeScript clients
518
551
- [Getting Started](./getting-started.md) - Build your first application
519
-
- [Concepts](./concepts.md) - Understand the architecture
552
+
- [Concepts](./concepts.md) - Understand the architecture
+7
-7
docs/concepts.md
+7
-7
docs/concepts.md
···
10
10
### Key Properties
11
11
12
12
- **URI**: Unique AT Protocol URI (e.g.,
13
-
`at://did:plc:abc123/social.slices.slice/3xyz`)
13
+
`at://did:plc:abc123/network.slices.slice/3xyz`)
14
14
- **Name**: Human-readable identifier
15
15
- **Domain**: Namespace for lexicons (e.g., `com.example`, `social.grain`)
16
16
- **Creation Date**: When the slice was created
···
65
65
Lexicons follow reverse domain naming:
66
66
67
67
- `com.example.post` - A post in the example.com namespace
68
-
- `social.slices.slice` - Core slice record type
68
+
- `network.slices.slice` - Core slice record type
69
69
- `app.bsky.actor.profile` - Bluesky profile (external)
70
70
71
71
## Collections
···
217
217
218
218
Built-in endpoints for slice management:
219
219
220
-
- `social.slices.slice.stats` - Slice statistics
221
-
- `social.slices.slice.records` - Browse records
222
-
- `social.slices.slice.codegen` - Generate SDKs
223
-
- `social.slices.slice.sync` - Trigger sync
220
+
- `network.slices.slice.stats` - Slice statistics
221
+
- `network.slices.slice.records` - Browse records
222
+
- `network.slices.slice.codegen` - Generate SDKs
223
+
- `network.slices.slice.sync` - Trigger sync
224
224
225
225
### Handler Authentication
226
226
···
255
255
256
256
// Use nested structure matching lexicons
257
257
await client.com.example.post.listRecords();
258
-
await client.social.slices.slice.stats();
258
+
await client.network.slices.slice.stats();
259
259
await client.app.bsky.actor.profile.getRecord({ uri });
260
260
```
261
261
+22
-9
docs/getting-started.md
+22
-9
docs/getting-started.md
···
38
38
Create `.env` files for both API and frontend:
39
39
40
40
**API (`/api/.env`)**:
41
+
41
42
```bash
42
43
DATABASE_URL=postgres://user:password@localhost:5432/slices
43
44
AUTH_BASE_URL=https://aip.your-domain.com
···
45
46
```
46
47
47
48
**Frontend (`/frontend/.env`)**:
49
+
48
50
```bash
49
51
OAUTH_CLIENT_ID=your-client-id
50
52
OAUTH_CLIENT_SECRET=your-client-secret
···
52
54
OAUTH_AIP_BASE_URL=https://aip.your-domain.com
53
55
SESSION_ENCRYPTION_KEY=your-32-char-key
54
56
API_URL=http://localhost:3000
55
-
SLICE_URI=at://did:plc:your-did/social.slices.slice/your-slice-id
57
+
SLICE_URI=at://did:plc:your-did/network.slices.slice/your-slice-id
56
58
DATABASE_URL=slices.db
57
59
```
58
60
···
70
72
### 5. Start the Services
71
73
72
74
Start the API server:
75
+
73
76
```bash
74
77
cd api
75
78
cargo run
76
79
```
77
80
78
81
Start the frontend:
82
+
79
83
```bash
80
84
cd frontend
81
85
deno task dev
···
92
96
### 2. Create a Slice
93
97
94
98
Click "Create Slice" and provide:
99
+
95
100
- **Name**: A friendly name for your slice
96
101
- **Domain**: Your namespace (e.g., `com.example`)
97
102
98
103
### 3. Define a Lexicon
99
104
100
-
Navigate to your slice and go to the Lexicon tab. Create a lexicon for your first record type:
105
+
Navigate to your slice and go to the Lexicon tab. Create a lexicon for your
106
+
first record type:
101
107
102
108
```json
103
109
{
···
140
146
141
147
### 4. Generate TypeScript Client
142
148
143
-
Navigate to the Code Generation tab and click "Generate TypeScript Client". This creates a type-safe client library for your slice.
149
+
Navigate to the Code Generation tab and click "Generate TypeScript Client". This
150
+
creates a type-safe client library for your slice.
144
151
145
152
### 5. Use the Generated Client
146
153
···
150
157
import { AtProtoClient } from "./generated-client.ts";
151
158
152
159
const client = new AtProtoClient(
153
-
'http://localhost:3000',
154
-
'at://did:plc:your-did/social.slices.slice/your-slice-id'
160
+
"http://localhost:3000",
161
+
"at://did:plc:your-did/network.slices.slice/your-slice-id",
155
162
);
156
163
157
164
// List posts
···
162
169
title: "My First Post",
163
170
content: "Hello from Slices!",
164
171
createdAt: new Date().toISOString(),
165
-
tags: ["introduction", "slices"]
172
+
tags: ["introduction", "slices"],
166
173
});
167
174
168
175
// Get a specific post
169
176
const post = await client.com.example.post.getRecord({
170
-
uri: newPost.uri
177
+
uri: newPost.uri,
171
178
});
172
179
```
173
180
···
182
189
### 2. Configure Sync
183
190
184
191
Choose collections to sync:
192
+
185
193
- **Primary Collections**: Your slice's lexicons
186
194
- **External Collections**: Bluesky or other AT Protocol collections
187
195
188
196
### 3. Start Sync
189
197
190
-
Specify repositories (DIDs) to sync from, or leave empty to sync all available data.
198
+
Specify repositories (DIDs) to sync from, or leave empty to sync all available
199
+
data.
191
200
192
201
### 4. Monitor Progress
193
202
···
203
212
## Troubleshooting
204
213
205
214
### Database Connection Issues
215
+
206
216
- Verify PostgreSQL is running: `docker ps`
207
217
- Check DATABASE_URL format
208
218
- Ensure database exists
209
219
210
220
### OAuth Errors
221
+
211
222
- Verify client ID and secret
212
223
- Check redirect URI matches configuration
213
224
- Ensure AIP server is accessible
214
225
215
226
### Sync Not Working
227
+
216
228
- Check user has necessary permissions
217
229
- Verify lexicons are valid
218
230
- Check API server logs for errors
219
231
220
232
### Generated Client Issues
233
+
221
234
- Regenerate client after lexicon changes
222
235
- Ensure API server is running
223
-
- Check for TypeScript compilation errors
236
+
- Check for TypeScript compilation errors
+44
-39
docs/sdk-usage.md
+44
-39
docs/sdk-usage.md
···
19
19
```typescript
20
20
const client = new AtProtoClient(
21
21
"https://api.your-domain.com",
22
-
"at://did:plc:abc/social.slices.slice/your-slice-id",
22
+
"at://did:plc:abc/network.slices.slice/your-slice-id",
23
23
);
24
24
25
25
// Read operations work without auth
···
43
43
// Initialize API client with OAuth
44
44
const client = new AtProtoClient(
45
45
"https://api.your-domain.com",
46
-
"at://did:plc:abc/social.slices.slice/your-slice-id",
46
+
"at://did:plc:abc/network.slices.slice/your-slice-id",
47
47
oauthClient,
48
48
);
49
49
```
···
292
292
### Get Slice Statistics
293
293
294
294
```typescript
295
-
const stats = await client.social.slices.slice.stats({
295
+
const stats = await client.network.slices.slice.stats({
296
296
slice: "at://your-slice-uri",
297
297
});
298
298
···
311
311
312
312
```typescript
313
313
// Get all actors in the slice
314
-
const actors = await client.social.slices.slice.getActors();
314
+
const actors = await client.network.slices.slice.getActors();
315
315
316
316
// With pagination
317
-
const page1 = await client.social.slices.slice.getActors({
317
+
const page1 = await client.network.slices.slice.getActors({
318
318
limit: 20,
319
319
});
320
-
const page2 = await client.social.slices.slice.getActors({
320
+
const page2 = await client.network.slices.slice.getActors({
321
321
limit: 20,
322
322
cursor: page1.cursor,
323
323
});
324
324
325
325
// Filter by specific DIDs
326
-
const specificActors = await client.social.slices.slice.getActors({
326
+
const specificActors = await client.network.slices.slice.getActors({
327
327
where: {
328
328
did: { in: ["did:plc:user1", "did:plc:user2"] },
329
329
},
330
330
});
331
331
332
332
// Search by handle
333
-
const searchByHandle = await client.social.slices.slice.getActors({
333
+
const searchByHandle = await client.network.slices.slice.getActors({
334
334
where: {
335
335
handle: { contains: "alice" },
336
336
},
337
337
});
338
338
339
339
// Filter by exact handle
340
-
const exactHandle = await client.social.slices.slice.getActors({
340
+
const exactHandle = await client.network.slices.slice.getActors({
341
341
where: {
342
342
handle: { eq: "alice.bsky.social" },
343
343
},
···
357
357
358
358
```typescript
359
359
// Get records from specific collections
360
-
const records = await client.social.slices.slice.getSliceRecords({
360
+
const records = await client.network.slices.slice.getSliceRecords({
361
361
where: {
362
362
collection: { eq: "com.example.post" },
363
363
did: { eq: "did:plc:specific-author" }, // optional
···
370
370
});
371
371
372
372
// Search across collections using specific fields
373
-
const searchResults = await client.social.slices.slice.getSliceRecords({
373
+
const searchResults = await client.network.slices.slice.getSliceRecords({
374
374
where: {
375
375
collection: { eq: "com.example.post" },
376
376
title: { contains: "hello world" },
···
380
380
});
381
381
382
382
// Global search across ALL fields in records
383
-
const globalSearchResults = await client.social.slices.slice.getSliceRecords({
383
+
const globalSearchResults = await client.network.slices.slice.getSliceRecords({
384
384
where: {
385
385
collection: { eq: "com.example.post" },
386
386
json: { contains: "hello world" }, // Searches entire record content
···
394
394
});
395
395
396
396
// Get records from any collection with global text search
397
-
const allCollectionSearch = await client.social.slices.slice.getSliceRecords({
397
+
const allCollectionSearch = await client.network.slices.slice.getSliceRecords({
398
398
where: {
399
399
json: { contains: "important content" }, // Searches ALL fields in ALL collections
400
400
},
···
451
451
452
452
```typescript
453
453
// Search for "tutorial" across all collections
454
-
const crossCollectionSearch = await client.social.slices.slice.getSliceRecords({
455
-
where: {
456
-
json: { contains: "tutorial" },
454
+
const crossCollectionSearch = await client.network.slices.slice.getSliceRecords(
455
+
{
456
+
where: {
457
+
json: { contains: "tutorial" },
458
+
},
457
459
},
458
-
});
460
+
);
459
461
460
462
// Limit to specific collections
461
-
const specificSearch = await client.social.slices.slice.getSliceRecords({
463
+
const specificSearch = await client.network.slices.slice.getSliceRecords({
462
464
where: {
463
465
collection: { in: ["com.example.post", "com.example.article"] },
464
466
json: { contains: "guide" },
···
468
470
469
471
### OR Query Support
470
472
471
-
You can use OR queries to find records that match any of multiple conditions using the separate `orWhere` parameter. This provides clean type safety and autocomplete for field names:
473
+
You can use OR queries to find records that match any of multiple conditions
474
+
using the separate `orWhere` parameter. This provides clean type safety and
475
+
autocomplete for field names:
472
476
473
477
```typescript
474
478
// Find posts by either user1 OR user2
475
479
const posts = await client.com.example.post.getRecords({
476
480
orWhere: {
477
-
did: { in: ["did:plc:user1", "did:plc:user2"] }
478
-
}
481
+
did: { in: ["did:plc:user1", "did:plc:user2"] },
482
+
},
479
483
});
480
484
481
485
// Find posts that either have "typescript" in title OR are by a specific user
482
486
const posts = await client.com.example.post.getRecords({
483
487
orWhere: {
484
488
title: { contains: "typescript" },
485
-
did: { eq: "did:plc:alice" }
486
-
}
489
+
did: { eq: "did:plc:alice" },
490
+
},
487
491
});
488
492
489
493
// Combining OR with regular AND conditions
490
494
const posts = await client.com.example.post.getRecords({
491
495
where: {
492
-
createdAt: { eq: "2025-09-03" }, // AND conditions
496
+
createdAt: { eq: "2025-09-03" }, // AND conditions
493
497
},
494
-
orWhere: { // OR conditions
498
+
orWhere: { // OR conditions
495
499
title: { contains: "guide" },
496
-
did: { eq: "did:plc:user1" }
497
-
}
500
+
did: { eq: "did:plc:user1" },
501
+
},
498
502
});
499
503
// SQL: WHERE created_at = '2025-09-03' AND (title LIKE '%guide%' OR did = 'did:plc:user1')
500
504
501
505
// OR queries work with cross-collection searches too
502
-
const crossCollectionOrSearch = await client.social.slices.slice.getSliceRecords({
503
-
where: {
504
-
collection: { eq: "com.example.post" },
505
-
},
506
-
orWhere: {
507
-
title: { contains: "javascript" },
508
-
tags: { contains: "tutorial" }
509
-
}
510
-
});
506
+
const crossCollectionOrSearch = await client.network.slices.slice
507
+
.getSliceRecords({
508
+
where: {
509
+
collection: { eq: "com.example.post" },
510
+
},
511
+
orWhere: {
512
+
title: { contains: "javascript" },
513
+
tags: { contains: "tutorial" },
514
+
},
515
+
});
511
516
512
517
// You get full autocomplete and type safety for field names in both where and orWhere
513
518
const typedSearch = await client.com.example.post.getRecords({
···
519
524
// And also provides autocomplete here
520
525
description: { contains: "tutorial" },
521
526
tags: { contains: "guide" },
522
-
}
527
+
},
523
528
});
524
529
```
525
530
···
527
532
528
533
```typescript
529
534
// Sync current user's data (requires auth)
530
-
const syncResult = await client.social.slices.slice.syncUserCollections({
535
+
const syncResult = await client.network.slices.slice.syncUserCollections({
531
536
timeoutSeconds: 30,
532
537
});
533
538
···
593
598
const client = new AtProtoClient(apiUrl, sliceUri, oauthClient);
594
599
595
600
// OAuth tokens are automatically managed
596
-
const profile = await client.social.slices.actor.profile.createRecord({
601
+
const profile = await client.network.slices.actor.profile.createRecord({
597
602
displayName: "New User",
598
603
description: "My profile",
599
604
}, true); // useSelfRkey for profile
+1
-1
frontend/.env.example
+1
-1
frontend/.env.example
···
3
3
OAUTH_REDIRECT_URI="http://localhost:8080/oauth/callback"
4
4
OAUTH_AIP_BASE_URL="https://your-domain.com"
5
5
API_URL="http://localhost:3000"
6
-
SLICE_URI="at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q"
6
+
SLICE_URI="at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z"
+1
-1
frontend/fly.toml
+1
-1
frontend/fly.toml
···
13
13
PORT = '8080'
14
14
API_URL = 'https://slices-api.fly.dev'
15
15
DATABASE_URL = '/data/slices.db'
16
-
SLICE_URI = 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q'
16
+
SLICE_URI = 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z'
17
17
DENO_ENV = 'production'
18
18
19
19
[http_service]
+127
-101
frontend/src/client.test.ts
+127
-101
frontend/src/client.test.ts
···
75
75
76
76
// Mock fetch function that simulates API responses
77
77
function createMockFetch() {
78
-
return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
78
+
return async (
79
+
url: string | URL | Request,
80
+
init?: RequestInit
81
+
): Promise<Response> => {
79
82
const requestUrl = url.toString();
80
83
const method = init?.method || "GET";
81
-
const headers = init?.headers as Record<string, string> || {};
82
-
84
+
const headers = (init?.headers as Record<string, string>) || {};
85
+
83
86
console.log(`Mock fetch: ${method} ${requestUrl}`);
84
87
console.log(`Authorization header: ${headers["Authorization"] || "none"}`);
85
-
88
+
86
89
// Check if request has valid authorization
87
-
const hasAuth = headers["Authorization"]?.startsWith("Bearer valid") ||
88
-
headers["Authorization"]?.startsWith("Bearer refreshed");
89
-
90
+
const hasAuth =
91
+
headers["Authorization"]?.startsWith("Bearer valid") ||
92
+
headers["Authorization"]?.startsWith("Bearer refreshed");
93
+
90
94
if (method === "GET") {
91
95
// GET requests always succeed (read-only operations)
92
-
return new Response(JSON.stringify({
93
-
records: [
94
-
{ uri: "test://uri", cid: "test-cid", value: { name: "Test Record" } }
95
-
]
96
-
}), {
97
-
status: 200,
98
-
headers: { "Content-Type": "application/json" }
99
-
});
96
+
return new Response(
97
+
JSON.stringify({
98
+
records: [
99
+
{
100
+
uri: "test://uri",
101
+
cid: "test-cid",
102
+
value: { name: "Test Record" },
103
+
},
104
+
],
105
+
}),
106
+
{
107
+
status: 200,
108
+
headers: { "Content-Type": "application/json" },
109
+
}
110
+
);
100
111
} else {
101
112
// POST/PUT/DELETE require valid auth
102
113
if (hasAuth) {
103
-
return new Response(JSON.stringify({
104
-
uri: "test://created-uri",
105
-
cid: "test-created-cid"
106
-
}), {
107
-
status: 200,
108
-
headers: { "Content-Type": "application/json" }
109
-
});
114
+
return new Response(
115
+
JSON.stringify({
116
+
uri: "test://created-uri",
117
+
cid: "test-created-cid",
118
+
}),
119
+
{
120
+
status: 200,
121
+
headers: { "Content-Type": "application/json" },
122
+
}
123
+
);
110
124
} else {
111
-
return new Response(JSON.stringify({
112
-
error: "Unauthorized"
113
-
}), {
114
-
status: 401,
115
-
headers: { "Content-Type": "application/json" }
116
-
});
125
+
return new Response(
126
+
JSON.stringify({
127
+
error: "Unauthorized",
128
+
}),
129
+
{
130
+
status: 401,
131
+
headers: { "Content-Type": "application/json" },
132
+
}
133
+
);
117
134
}
118
135
}
119
136
};
···
125
142
const mockOAuth = new MockOAuthClient(tokenState);
126
143
return new AtProtoClient(
127
144
"https://test-api.example.com",
128
-
"at://did:plc:test/social.slices.slice/test",
145
+
"at://did:plc:test/network.slices.slice/test",
129
146
mockOAuth as any
130
147
);
131
148
}
···
133
150
Deno.test("Valid tokens - read operation should succeed", async () => {
134
151
// Setup mock fetch
135
152
globalThis.fetch = createMockFetch();
136
-
153
+
137
154
try {
138
155
const client = createTestClient("valid");
139
-
await client.social.slices.slice.getRecords();
156
+
await client.network.slices.slice.getRecords();
140
157
console.log("Read operation succeeded as expected");
141
158
} catch (error) {
142
159
throw new Error(`Read operation should have succeeded: ${error}`);
···
149
166
Deno.test("Valid tokens - write operation should succeed", async () => {
150
167
// Setup mock fetch
151
168
globalThis.fetch = createMockFetch();
152
-
169
+
153
170
try {
154
171
const client = createTestClient("valid");
155
-
await client.social.slices.slice.updateRecord("test", {
172
+
await client.network.slices.slice.updateRecord("test", {
156
173
name: "Test Slice",
157
174
createdAt: new Date().toISOString(),
158
175
});
···
170
187
async () => {
171
188
// Setup mock fetch
172
189
globalThis.fetch = createMockFetch();
173
-
190
+
174
191
try {
175
192
const client = createTestClient("expired");
176
-
await client.social.slices.slice.getRecords();
193
+
await client.network.slices.slice.getRecords();
177
194
console.log("Read operation succeeded after token refresh");
178
195
} catch (error) {
179
196
throw new Error(
···
191
208
async () => {
192
209
// Setup mock fetch
193
210
globalThis.fetch = createMockFetch();
194
-
211
+
195
212
try {
196
213
const client = createTestClient("expired");
197
-
await client.social.slices.slice.updateRecord("test", {
214
+
await client.network.slices.slice.updateRecord("test", {
198
215
name: "Test Slice",
199
216
createdAt: new Date().toISOString(),
200
217
});
···
213
230
Deno.test("Token refresh fails - read operation should succeed", async () => {
214
231
// Setup mock fetch
215
232
globalThis.fetch = createMockFetch();
216
-
233
+
217
234
try {
218
235
const client = createTestClient("refresh_fails");
219
-
await client.social.slices.slice.getRecords();
236
+
await client.network.slices.slice.getRecords();
220
237
console.log("Read operation succeeded without auth (as expected)");
221
238
} catch (error) {
222
239
throw new Error(
···
233
250
async () => {
234
251
// Setup mock fetch
235
252
globalThis.fetch = createMockFetch();
236
-
253
+
237
254
try {
238
255
const client = createTestClient("refresh_fails");
239
256
await assertRejects(
240
257
async () => {
241
-
await client.social.slices.slice.updateRecord("test", {
258
+
await client.network.slices.slice.updateRecord("test", {
242
259
name: "Test Slice",
243
260
createdAt: new Date().toISOString(),
244
261
});
···
257
274
Deno.test("No tokens - read operation should succeed", async () => {
258
275
// Setup mock fetch
259
276
globalThis.fetch = createMockFetch();
260
-
277
+
261
278
try {
262
279
const client = createTestClient("no_tokens");
263
-
await client.social.slices.slice.getRecords();
280
+
await client.network.slices.slice.getRecords();
264
281
console.log("Read operation succeeded without tokens (as expected)");
265
282
} catch (error) {
266
283
throw new Error(
···
277
294
async () => {
278
295
// Setup mock fetch
279
296
globalThis.fetch = createMockFetch();
280
-
297
+
281
298
try {
282
299
const client = createTestClient("no_tokens");
283
300
await assertRejects(
284
301
async () => {
285
-
await client.social.slices.slice.updateRecord("test", {
302
+
await client.network.slices.slice.updateRecord("test", {
286
303
name: "Test Slice",
287
304
createdAt: new Date().toISOString(),
288
305
});
···
298
315
}
299
316
);
300
317
301
-
Deno.test(
302
-
"401 response triggers token refresh and retry",
303
-
async () => {
304
-
// Create a fetch that returns 401 first time, 200 second time
305
-
let callCount = 0;
306
-
const mockFetch = (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
307
-
callCount++;
308
-
const requestUrl = url.toString();
309
-
const method = init?.method || "GET";
310
-
const headers = init?.headers as Record<string, string> || {};
311
-
312
-
console.log(`Mock fetch call ${callCount}: ${method} ${requestUrl}`);
313
-
console.log(`Authorization header: ${headers["Authorization"] || "none"}`);
314
-
315
-
if (method === "POST") {
316
-
if (callCount === 1) {
317
-
// First call returns 401
318
-
console.log("First call - returning 401 Unauthorized");
319
-
return Promise.resolve(new Response(JSON.stringify({ error: "Unauthorized" }), {
318
+
Deno.test("401 response triggers token refresh and retry", async () => {
319
+
// Create a fetch that returns 401 first time, 200 second time
320
+
let callCount = 0;
321
+
const mockFetch = (
322
+
url: string | URL | Request,
323
+
init?: RequestInit
324
+
): Promise<Response> => {
325
+
callCount++;
326
+
const requestUrl = url.toString();
327
+
const method = init?.method || "GET";
328
+
const headers = (init?.headers as Record<string, string>) || {};
329
+
330
+
console.log(`Mock fetch call ${callCount}: ${method} ${requestUrl}`);
331
+
console.log(`Authorization header: ${headers["Authorization"] || "none"}`);
332
+
333
+
if (method === "POST") {
334
+
if (callCount === 1) {
335
+
// First call returns 401
336
+
console.log("First call - returning 401 Unauthorized");
337
+
return Promise.resolve(
338
+
new Response(JSON.stringify({ error: "Unauthorized" }), {
320
339
status: 401,
321
-
headers: { "Content-Type": "application/json" }
322
-
}));
323
-
} else {
324
-
// Second call should succeed (with refreshed token)
325
-
console.log("Second call - returning success");
326
-
return Promise.resolve(new Response(JSON.stringify({
327
-
uri: "test://created-uri",
328
-
cid: "test-created-cid"
329
-
}), {
330
-
status: 200,
331
-
headers: { "Content-Type": "application/json" }
332
-
}));
333
-
}
334
-
}
335
-
336
-
return Promise.resolve(new Response("Not found", { status: 404 }));
337
-
};
338
-
339
-
globalThis.fetch = mockFetch;
340
-
341
-
try {
342
-
const client = createTestClient("valid");
343
-
await client.social.slices.slice.updateRecord("test", {
344
-
name: "Test Slice",
345
-
createdAt: new Date().toISOString(),
346
-
});
347
-
348
-
// Should have made exactly 2 calls
349
-
console.log(`Total fetch calls made: ${callCount}`);
350
-
if (callCount !== 2) {
351
-
throw new Error(`Expected 2 fetch calls but got ${callCount}`);
340
+
headers: { "Content-Type": "application/json" },
341
+
})
342
+
);
343
+
} else {
344
+
// Second call should succeed (with refreshed token)
345
+
console.log("Second call - returning success");
346
+
return Promise.resolve(
347
+
new Response(
348
+
JSON.stringify({
349
+
uri: "test://created-uri",
350
+
cid: "test-created-cid",
351
+
}),
352
+
{
353
+
status: 200,
354
+
headers: { "Content-Type": "application/json" },
355
+
}
356
+
)
357
+
);
352
358
}
353
-
354
-
console.log("401 retry test passed - request was retried after token refresh");
355
-
} finally {
356
-
// Restore original fetch
357
-
globalThis.fetch = originalFetch;
359
+
}
360
+
361
+
return Promise.resolve(new Response("Not found", { status: 404 }));
362
+
};
363
+
364
+
globalThis.fetch = mockFetch;
365
+
366
+
try {
367
+
const client = createTestClient("valid");
368
+
await client.network.slices.slice.updateRecord("test", {
369
+
name: "Test Slice",
370
+
createdAt: new Date().toISOString(),
371
+
});
372
+
373
+
// Should have made exactly 2 calls
374
+
console.log(`Total fetch calls made: ${callCount}`);
375
+
if (callCount !== 2) {
376
+
throw new Error(`Expected 2 fetch calls but got ${callCount}`);
358
377
}
378
+
379
+
console.log(
380
+
"401 retry test passed - request was retried after token refresh"
381
+
);
382
+
} finally {
383
+
// Restore original fetch
384
+
globalThis.fetch = originalFetch;
359
385
}
360
-
);
386
+
});
+113
-105
frontend/src/client.ts
+113
-105
frontend/src/client.ts
···
1
1
// Generated TypeScript client for AT Protocol records
2
-
// Generated at: 2025-09-07 23:17:29 UTC
2
+
// Generated at: 2025-09-12 05:27:35 UTC
3
3
// Lexicons: 6
4
4
5
5
/**
···
9
9
*
10
10
* const client = new AtProtoClient(
11
11
* 'https://slices-api.fly.dev',
12
-
* 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lx5zq4t56s2q'
12
+
* 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z'
13
13
* );
14
14
*
15
15
* // Get records from the app.bsky.actor.profile collection
···
28
28
* });
29
29
*
30
30
* // Use slice-level methods for cross-collection queries with type safety
31
-
* const sliceRecords = await client.social.slices.slice.getSliceRecords<AppBskyActorProfile>({
31
+
* const sliceRecords = await client.network.slices.slice.getSliceRecords<AppBskyActorProfile>({
32
32
* where: {
33
33
* collection: { eq: 'app.bsky.actor.profile' }
34
34
* }
35
35
* });
36
36
*
37
37
* // Search across multiple collections using union types
38
-
* const multiCollectionRecords = await client.social.slices.slice.getSliceRecords<AppBskyActorProfile | AppBskyActorProfile>({
38
+
* const multiCollectionRecords = await client.network.slices.slice.getSliceRecords<AppBskyActorProfile | AppBskyActorProfile>({
39
39
* where: {
40
40
* collection: { in: ['app.bsky.actor.profile', 'app.bsky.actor.profile'] },
41
41
* text: { contains: 'example search term' },
···
379
379
| "description"
380
380
| "displayName";
381
381
382
-
export interface SocialSlicesSlice {
382
+
export interface NetworkSlicesSlice {
383
383
/** Name of the slice */
384
384
name: string;
385
385
/** Primary domain namespace for this slice (e.g. social.grain) */
···
388
388
createdAt: string;
389
389
}
390
390
391
-
export type SocialSlicesSliceSortFields = "name" | "domain" | "createdAt";
391
+
export type NetworkSlicesSliceSortFields = "name" | "domain" | "createdAt";
392
392
393
-
export interface SocialSlicesLexicon {
393
+
export interface NetworkSlicesLexicon {
394
394
/** Namespaced identifier for the lexicon */
395
395
nsid: string;
396
396
/** The lexicon schema definitions as JSON */
···
403
403
slice: string;
404
404
}
405
405
406
-
export type SocialSlicesLexiconSortFields =
406
+
export type NetworkSlicesLexiconSortFields =
407
407
| "nsid"
408
408
| "definitions"
409
409
| "createdAt"
410
410
| "updatedAt"
411
411
| "slice";
412
412
413
-
export interface SocialSlicesActorProfile {
413
+
export interface NetworkSlicesActorProfile {
414
414
displayName?: string;
415
415
/** Free-form profile description text. */
416
416
description?: string;
···
419
419
createdAt?: string;
420
420
}
421
421
422
-
export type SocialSlicesActorProfileSortFields =
422
+
export type NetworkSlicesActorProfileSortFields =
423
423
| "displayName"
424
424
| "description"
425
425
| "createdAt";
···
791
791
}
792
792
}
793
793
794
-
class SliceSlicesSocialClient extends BaseClient {
794
+
class SliceSlicesNetworkClient extends BaseClient {
795
795
private readonly sliceUri: string;
796
796
797
797
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
···
803
803
limit?: number;
804
804
cursor?: string;
805
805
where?: {
806
-
[K in SocialSlicesSliceSortFields | IndexedRecordFields]?: WhereCondition;
806
+
[K in
807
+
| NetworkSlicesSliceSortFields
808
+
| IndexedRecordFields]?: WhereCondition;
807
809
};
808
810
orWhere?: {
809
-
[K in SocialSlicesSliceSortFields | IndexedRecordFields]?: WhereCondition;
811
+
[K in
812
+
| NetworkSlicesSliceSortFields
813
+
| IndexedRecordFields]?: WhereCondition;
810
814
};
811
-
sortBy?: SortField<SocialSlicesSliceSortFields>[];
812
-
}): Promise<GetRecordsResponse<SocialSlicesSlice>> {
815
+
sortBy?: SortField<NetworkSlicesSliceSortFields>[];
816
+
}): Promise<GetRecordsResponse<NetworkSlicesSlice>> {
813
817
// Combine where and orWhere into the expected backend format
814
818
const whereClause: any = params?.where ? { ...params.where } : {};
815
819
if (params?.orWhere) {
···
823
827
slice: this.sliceUri,
824
828
};
825
829
const result = await this.makeRequest<SliceRecordsOutput>(
826
-
"social.slices.slice.getRecords",
830
+
"network.slices.slice.getRecords",
827
831
"POST",
828
832
requestParams
829
833
);
···
833
837
cid: record.cid,
834
838
did: record.did,
835
839
collection: record.collection,
836
-
value: record.value as unknown as SocialSlicesSlice,
840
+
value: record.value as unknown as NetworkSlicesSlice,
837
841
indexedAt: record.indexedAt,
838
842
})),
839
843
cursor: result.cursor,
···
842
846
843
847
async getRecord(
844
848
params: GetRecordParams
845
-
): Promise<RecordResponse<SocialSlicesSlice>> {
849
+
): Promise<RecordResponse<NetworkSlicesSlice>> {
846
850
const requestParams = { ...params, slice: this.sliceUri };
847
-
return await this.makeRequest<RecordResponse<SocialSlicesSlice>>(
848
-
"social.slices.slice.getRecord",
851
+
return await this.makeRequest<RecordResponse<NetworkSlicesSlice>>(
852
+
"network.slices.slice.getRecord",
849
853
"GET",
850
854
requestParams
851
855
);
···
855
859
limit?: number;
856
860
cursor?: string;
857
861
where?: {
858
-
[K in SocialSlicesSliceSortFields | IndexedRecordFields]?: WhereCondition;
862
+
[K in
863
+
| NetworkSlicesSliceSortFields
864
+
| IndexedRecordFields]?: WhereCondition;
859
865
};
860
866
orWhere?: {
861
-
[K in SocialSlicesSliceSortFields | IndexedRecordFields]?: WhereCondition;
867
+
[K in
868
+
| NetworkSlicesSliceSortFields
869
+
| IndexedRecordFields]?: WhereCondition;
862
870
};
863
-
sortBy?: SortField<SocialSlicesSliceSortFields>[];
871
+
sortBy?: SortField<NetworkSlicesSliceSortFields>[];
864
872
}): Promise<CountRecordsResponse> {
865
873
// Combine where and orWhere into the expected backend format
866
874
const whereClause: any = params?.where ? { ...params.where } : {};
···
875
883
slice: this.sliceUri,
876
884
};
877
885
return await this.makeRequest<CountRecordsResponse>(
878
-
"social.slices.slice.countRecords",
886
+
"network.slices.slice.countRecords",
879
887
"POST",
880
888
requestParams
881
889
);
882
890
}
883
891
884
892
async createRecord(
885
-
record: SocialSlicesSlice,
893
+
record: NetworkSlicesSlice,
886
894
useSelfRkey?: boolean
887
895
): Promise<{ uri: string; cid: string }> {
888
-
const recordValue = { $type: "social.slices.slice", ...record };
896
+
const recordValue = { $type: "network.slices.slice", ...record };
889
897
const payload = {
890
898
slice: this.sliceUri,
891
899
...(useSelfRkey ? { rkey: "self" } : {}),
892
900
record: recordValue,
893
901
};
894
902
return await this.makeRequest<{ uri: string; cid: string }>(
895
-
"social.slices.slice.createRecord",
903
+
"network.slices.slice.createRecord",
896
904
"POST",
897
905
payload
898
906
);
···
900
908
901
909
async updateRecord(
902
910
rkey: string,
903
-
record: SocialSlicesSlice
911
+
record: NetworkSlicesSlice
904
912
): Promise<{ uri: string; cid: string }> {
905
-
const recordValue = { $type: "social.slices.slice", ...record };
913
+
const recordValue = { $type: "network.slices.slice", ...record };
906
914
const payload = {
907
915
slice: this.sliceUri,
908
916
rkey,
909
917
record: recordValue,
910
918
};
911
919
return await this.makeRequest<{ uri: string; cid: string }>(
912
-
"social.slices.slice.updateRecord",
920
+
"network.slices.slice.updateRecord",
913
921
"POST",
914
922
payload
915
923
);
···
917
925
918
926
async deleteRecord(rkey: string): Promise<void> {
919
927
return await this.makeRequest<void>(
920
-
"social.slices.slice.deleteRecord",
928
+
"network.slices.slice.deleteRecord",
921
929
"POST",
922
930
{ rkey }
923
931
);
···
925
933
926
934
async codegen(request: CodegenXrpcRequest): Promise<CodegenXrpcResponse> {
927
935
return await this.makeRequest<CodegenXrpcResponse>(
928
-
"social.slices.slice.codegen",
936
+
"network.slices.slice.codegen",
929
937
"POST",
930
938
request
931
939
);
···
933
941
934
942
async stats(params: SliceStatsParams): Promise<SliceStatsOutput> {
935
943
return await this.makeRequest<SliceStatsOutput>(
936
-
"social.slices.slice.stats",
944
+
"network.slices.slice.stats",
937
945
"POST",
938
946
params
939
947
);
···
955
963
slice: this.sliceUri,
956
964
};
957
965
return await this.makeRequest<SliceRecordsOutput<T>>(
958
-
"social.slices.slice.getSliceRecords",
966
+
"network.slices.slice.getSliceRecords",
959
967
"POST",
960
968
requestParams
961
969
);
···
964
972
async getActors(params?: GetActorsParams): Promise<GetActorsResponse> {
965
973
const requestParams = { ...params, slice: this.sliceUri };
966
974
return await this.makeRequest<GetActorsResponse>(
967
-
"social.slices.slice.getActors",
975
+
"network.slices.slice.getActors",
968
976
"POST",
969
977
requestParams
970
978
);
···
973
981
async startSync(params: BulkSyncParams): Promise<SyncJobResponse> {
974
982
const requestParams = { ...params, slice: this.sliceUri };
975
983
return await this.makeRequest<SyncJobResponse>(
976
-
"social.slices.slice.startSync",
984
+
"network.slices.slice.startSync",
977
985
"POST",
978
986
requestParams
979
987
);
···
981
989
982
990
async getJobStatus(params: GetJobStatusParams): Promise<JobStatus> {
983
991
return await this.makeRequest<JobStatus>(
984
-
"social.slices.slice.getJobStatus",
992
+
"network.slices.slice.getJobStatus",
985
993
"GET",
986
994
params
987
995
);
···
991
999
params: GetJobHistoryParams
992
1000
): Promise<GetJobHistoryResponse> {
993
1001
return await this.makeRequest<GetJobHistoryResponse>(
994
-
"social.slices.slice.getJobHistory",
1002
+
"network.slices.slice.getJobHistory",
995
1003
"GET",
996
1004
params
997
1005
);
···
999
1007
1000
1008
async getJobLogs(params: GetJobLogsParams): Promise<GetJobLogsResponse> {
1001
1009
return await this.makeRequest<GetJobLogsResponse>(
1002
-
"social.slices.slice.getJobLogs",
1010
+
"network.slices.slice.getJobLogs",
1003
1011
"GET",
1004
1012
params
1005
1013
);
···
1007
1015
1008
1016
async getJetstreamStatus(): Promise<JetstreamStatusResponse> {
1009
1017
return await this.makeRequest<JetstreamStatusResponse>(
1010
-
"social.slices.slice.getJetstreamStatus",
1018
+
"network.slices.slice.getJetstreamStatus",
1011
1019
"GET"
1012
1020
);
1013
1021
}
···
1016
1024
params: GetJetstreamLogsParams
1017
1025
): Promise<GetJetstreamLogsResponse> {
1018
1026
return await this.makeRequest<GetJetstreamLogsResponse>(
1019
-
"social.slices.slice.getJetstreamLogs",
1027
+
"network.slices.slice.getJetstreamLogs",
1020
1028
"GET",
1021
1029
params
1022
1030
);
···
1027
1035
): Promise<SyncUserCollectionsResult> {
1028
1036
const requestParams = { slice: this.sliceUri, ...params };
1029
1037
return await this.makeRequest<SyncUserCollectionsResult>(
1030
-
"social.slices.slice.syncUserCollections",
1038
+
"network.slices.slice.syncUserCollections",
1031
1039
"POST",
1032
1040
requestParams
1033
1041
);
···
1038
1046
): Promise<OAuthClientDetails> {
1039
1047
const requestParams = { ...params, sliceUri: this.sliceUri };
1040
1048
return await this.makeRequest<OAuthClientDetails>(
1041
-
"social.slices.slice.createOAuthClient",
1049
+
"network.slices.slice.createOAuthClient",
1042
1050
"POST",
1043
1051
requestParams
1044
1052
);
···
1047
1055
async getOAuthClients(): Promise<ListOAuthClientsResponse> {
1048
1056
const requestParams = { slice: this.sliceUri };
1049
1057
return await this.makeRequest<ListOAuthClientsResponse>(
1050
-
"social.slices.slice.getOAuthClients",
1058
+
"network.slices.slice.getOAuthClients",
1051
1059
"GET",
1052
1060
requestParams
1053
1061
);
···
1058
1066
): Promise<OAuthClientDetails> {
1059
1067
const requestParams = { ...params, sliceUri: this.sliceUri };
1060
1068
return await this.makeRequest<OAuthClientDetails>(
1061
-
"social.slices.slice.updateOAuthClient",
1069
+
"network.slices.slice.updateOAuthClient",
1062
1070
"POST",
1063
1071
requestParams
1064
1072
);
···
1068
1076
clientId: string
1069
1077
): Promise<DeleteOAuthClientResponse> {
1070
1078
return await this.makeRequest<DeleteOAuthClientResponse>(
1071
-
"social.slices.slice.deleteOAuthClient",
1079
+
"network.slices.slice.deleteOAuthClient",
1072
1080
"POST",
1073
1081
{ clientId }
1074
1082
);
1075
1083
}
1076
1084
}
1077
1085
1078
-
class LexiconSlicesSocialClient extends BaseClient {
1086
+
class LexiconSlicesNetworkClient extends BaseClient {
1079
1087
private readonly sliceUri: string;
1080
1088
1081
1089
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
···
1088
1096
cursor?: string;
1089
1097
where?: {
1090
1098
[K in
1091
-
| SocialSlicesLexiconSortFields
1099
+
| NetworkSlicesLexiconSortFields
1092
1100
| IndexedRecordFields]?: WhereCondition;
1093
1101
};
1094
1102
orWhere?: {
1095
1103
[K in
1096
-
| SocialSlicesLexiconSortFields
1104
+
| NetworkSlicesLexiconSortFields
1097
1105
| IndexedRecordFields]?: WhereCondition;
1098
1106
};
1099
-
sortBy?: SortField<SocialSlicesLexiconSortFields>[];
1100
-
}): Promise<GetRecordsResponse<SocialSlicesLexicon>> {
1107
+
sortBy?: SortField<NetworkSlicesLexiconSortFields>[];
1108
+
}): Promise<GetRecordsResponse<NetworkSlicesLexicon>> {
1101
1109
// Combine where and orWhere into the expected backend format
1102
1110
const whereClause: any = params?.where ? { ...params.where } : {};
1103
1111
if (params?.orWhere) {
···
1111
1119
slice: this.sliceUri,
1112
1120
};
1113
1121
const result = await this.makeRequest<SliceRecordsOutput>(
1114
-
"social.slices.lexicon.getRecords",
1122
+
"network.slices.lexicon.getRecords",
1115
1123
"POST",
1116
1124
requestParams
1117
1125
);
···
1121
1129
cid: record.cid,
1122
1130
did: record.did,
1123
1131
collection: record.collection,
1124
-
value: record.value as unknown as SocialSlicesLexicon,
1132
+
value: record.value as unknown as NetworkSlicesLexicon,
1125
1133
indexedAt: record.indexedAt,
1126
1134
})),
1127
1135
cursor: result.cursor,
···
1130
1138
1131
1139
async getRecord(
1132
1140
params: GetRecordParams
1133
-
): Promise<RecordResponse<SocialSlicesLexicon>> {
1141
+
): Promise<RecordResponse<NetworkSlicesLexicon>> {
1134
1142
const requestParams = { ...params, slice: this.sliceUri };
1135
-
return await this.makeRequest<RecordResponse<SocialSlicesLexicon>>(
1136
-
"social.slices.lexicon.getRecord",
1143
+
return await this.makeRequest<RecordResponse<NetworkSlicesLexicon>>(
1144
+
"network.slices.lexicon.getRecord",
1137
1145
"GET",
1138
1146
requestParams
1139
1147
);
···
1144
1152
cursor?: string;
1145
1153
where?: {
1146
1154
[K in
1147
-
| SocialSlicesLexiconSortFields
1155
+
| NetworkSlicesLexiconSortFields
1148
1156
| IndexedRecordFields]?: WhereCondition;
1149
1157
};
1150
1158
orWhere?: {
1151
1159
[K in
1152
-
| SocialSlicesLexiconSortFields
1160
+
| NetworkSlicesLexiconSortFields
1153
1161
| IndexedRecordFields]?: WhereCondition;
1154
1162
};
1155
-
sortBy?: SortField<SocialSlicesLexiconSortFields>[];
1163
+
sortBy?: SortField<NetworkSlicesLexiconSortFields>[];
1156
1164
}): Promise<CountRecordsResponse> {
1157
1165
// Combine where and orWhere into the expected backend format
1158
1166
const whereClause: any = params?.where ? { ...params.where } : {};
···
1167
1175
slice: this.sliceUri,
1168
1176
};
1169
1177
return await this.makeRequest<CountRecordsResponse>(
1170
-
"social.slices.lexicon.countRecords",
1178
+
"network.slices.lexicon.countRecords",
1171
1179
"POST",
1172
1180
requestParams
1173
1181
);
1174
1182
}
1175
1183
1176
1184
async createRecord(
1177
-
record: SocialSlicesLexicon,
1185
+
record: NetworkSlicesLexicon,
1178
1186
useSelfRkey?: boolean
1179
1187
): Promise<{ uri: string; cid: string }> {
1180
-
const recordValue = { $type: "social.slices.lexicon", ...record };
1188
+
const recordValue = { $type: "network.slices.lexicon", ...record };
1181
1189
const payload = {
1182
1190
slice: this.sliceUri,
1183
1191
...(useSelfRkey ? { rkey: "self" } : {}),
1184
1192
record: recordValue,
1185
1193
};
1186
1194
return await this.makeRequest<{ uri: string; cid: string }>(
1187
-
"social.slices.lexicon.createRecord",
1195
+
"network.slices.lexicon.createRecord",
1188
1196
"POST",
1189
1197
payload
1190
1198
);
···
1192
1200
1193
1201
async updateRecord(
1194
1202
rkey: string,
1195
-
record: SocialSlicesLexicon
1203
+
record: NetworkSlicesLexicon
1196
1204
): Promise<{ uri: string; cid: string }> {
1197
-
const recordValue = { $type: "social.slices.lexicon", ...record };
1205
+
const recordValue = { $type: "network.slices.lexicon", ...record };
1198
1206
const payload = {
1199
1207
slice: this.sliceUri,
1200
1208
rkey,
1201
1209
record: recordValue,
1202
1210
};
1203
1211
return await this.makeRequest<{ uri: string; cid: string }>(
1204
-
"social.slices.lexicon.updateRecord",
1212
+
"network.slices.lexicon.updateRecord",
1205
1213
"POST",
1206
1214
payload
1207
1215
);
···
1209
1217
1210
1218
async deleteRecord(rkey: string): Promise<void> {
1211
1219
return await this.makeRequest<void>(
1212
-
"social.slices.lexicon.deleteRecord",
1220
+
"network.slices.lexicon.deleteRecord",
1213
1221
"POST",
1214
1222
{ rkey }
1215
1223
);
1216
1224
}
1217
1225
}
1218
1226
1219
-
class ProfileActorSlicesSocialClient extends BaseClient {
1227
+
class ProfileActorSlicesNetworkClient extends BaseClient {
1220
1228
private readonly sliceUri: string;
1221
1229
1222
1230
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
···
1229
1237
cursor?: string;
1230
1238
where?: {
1231
1239
[K in
1232
-
| SocialSlicesActorProfileSortFields
1240
+
| NetworkSlicesActorProfileSortFields
1233
1241
| IndexedRecordFields]?: WhereCondition;
1234
1242
};
1235
1243
orWhere?: {
1236
1244
[K in
1237
-
| SocialSlicesActorProfileSortFields
1245
+
| NetworkSlicesActorProfileSortFields
1238
1246
| IndexedRecordFields]?: WhereCondition;
1239
1247
};
1240
-
sortBy?: SortField<SocialSlicesActorProfileSortFields>[];
1241
-
}): Promise<GetRecordsResponse<SocialSlicesActorProfile>> {
1248
+
sortBy?: SortField<NetworkSlicesActorProfileSortFields>[];
1249
+
}): Promise<GetRecordsResponse<NetworkSlicesActorProfile>> {
1242
1250
// Combine where and orWhere into the expected backend format
1243
1251
const whereClause: any = params?.where ? { ...params.where } : {};
1244
1252
if (params?.orWhere) {
···
1252
1260
slice: this.sliceUri,
1253
1261
};
1254
1262
const result = await this.makeRequest<SliceRecordsOutput>(
1255
-
"social.slices.actor.profile.getRecords",
1263
+
"network.slices.actor.profile.getRecords",
1256
1264
"POST",
1257
1265
requestParams
1258
1266
);
···
1262
1270
cid: record.cid,
1263
1271
did: record.did,
1264
1272
collection: record.collection,
1265
-
value: record.value as unknown as SocialSlicesActorProfile,
1273
+
value: record.value as unknown as NetworkSlicesActorProfile,
1266
1274
indexedAt: record.indexedAt,
1267
1275
})),
1268
1276
cursor: result.cursor,
···
1271
1279
1272
1280
async getRecord(
1273
1281
params: GetRecordParams
1274
-
): Promise<RecordResponse<SocialSlicesActorProfile>> {
1282
+
): Promise<RecordResponse<NetworkSlicesActorProfile>> {
1275
1283
const requestParams = { ...params, slice: this.sliceUri };
1276
-
return await this.makeRequest<RecordResponse<SocialSlicesActorProfile>>(
1277
-
"social.slices.actor.profile.getRecord",
1284
+
return await this.makeRequest<RecordResponse<NetworkSlicesActorProfile>>(
1285
+
"network.slices.actor.profile.getRecord",
1278
1286
"GET",
1279
1287
requestParams
1280
1288
);
···
1285
1293
cursor?: string;
1286
1294
where?: {
1287
1295
[K in
1288
-
| SocialSlicesActorProfileSortFields
1296
+
| NetworkSlicesActorProfileSortFields
1289
1297
| IndexedRecordFields]?: WhereCondition;
1290
1298
};
1291
1299
orWhere?: {
1292
1300
[K in
1293
-
| SocialSlicesActorProfileSortFields
1301
+
| NetworkSlicesActorProfileSortFields
1294
1302
| IndexedRecordFields]?: WhereCondition;
1295
1303
};
1296
-
sortBy?: SortField<SocialSlicesActorProfileSortFields>[];
1304
+
sortBy?: SortField<NetworkSlicesActorProfileSortFields>[];
1297
1305
}): Promise<CountRecordsResponse> {
1298
1306
// Combine where and orWhere into the expected backend format
1299
1307
const whereClause: any = params?.where ? { ...params.where } : {};
···
1308
1316
slice: this.sliceUri,
1309
1317
};
1310
1318
return await this.makeRequest<CountRecordsResponse>(
1311
-
"social.slices.actor.profile.countRecords",
1319
+
"network.slices.actor.profile.countRecords",
1312
1320
"POST",
1313
1321
requestParams
1314
1322
);
1315
1323
}
1316
1324
1317
1325
async createRecord(
1318
-
record: SocialSlicesActorProfile,
1326
+
record: NetworkSlicesActorProfile,
1319
1327
useSelfRkey?: boolean
1320
1328
): Promise<{ uri: string; cid: string }> {
1321
-
const recordValue = { $type: "social.slices.actor.profile", ...record };
1329
+
const recordValue = { $type: "network.slices.actor.profile", ...record };
1322
1330
const payload = {
1323
1331
slice: this.sliceUri,
1324
1332
...(useSelfRkey ? { rkey: "self" } : {}),
1325
1333
record: recordValue,
1326
1334
};
1327
1335
return await this.makeRequest<{ uri: string; cid: string }>(
1328
-
"social.slices.actor.profile.createRecord",
1336
+
"network.slices.actor.profile.createRecord",
1329
1337
"POST",
1330
1338
payload
1331
1339
);
···
1333
1341
1334
1342
async updateRecord(
1335
1343
rkey: string,
1336
-
record: SocialSlicesActorProfile
1344
+
record: NetworkSlicesActorProfile
1337
1345
): Promise<{ uri: string; cid: string }> {
1338
-
const recordValue = { $type: "social.slices.actor.profile", ...record };
1346
+
const recordValue = { $type: "network.slices.actor.profile", ...record };
1339
1347
const payload = {
1340
1348
slice: this.sliceUri,
1341
1349
rkey,
1342
1350
record: recordValue,
1343
1351
};
1344
1352
return await this.makeRequest<{ uri: string; cid: string }>(
1345
-
"social.slices.actor.profile.updateRecord",
1353
+
"network.slices.actor.profile.updateRecord",
1346
1354
"POST",
1347
1355
payload
1348
1356
);
···
1350
1358
1351
1359
async deleteRecord(rkey: string): Promise<void> {
1352
1360
return await this.makeRequest<void>(
1353
-
"social.slices.actor.profile.deleteRecord",
1361
+
"network.slices.actor.profile.deleteRecord",
1354
1362
"POST",
1355
1363
{ rkey }
1356
1364
);
1357
1365
}
1358
1366
}
1359
1367
1360
-
class ActorSlicesSocialClient extends BaseClient {
1361
-
readonly profile: ProfileActorSlicesSocialClient;
1368
+
class ActorSlicesNetworkClient extends BaseClient {
1369
+
readonly profile: ProfileActorSlicesNetworkClient;
1362
1370
private readonly sliceUri: string;
1363
1371
1364
1372
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
1365
1373
super(baseUrl, oauthClient);
1366
1374
this.sliceUri = sliceUri;
1367
-
this.profile = new ProfileActorSlicesSocialClient(
1375
+
this.profile = new ProfileActorSlicesNetworkClient(
1368
1376
baseUrl,
1369
1377
sliceUri,
1370
1378
oauthClient
···
1372
1380
}
1373
1381
}
1374
1382
1375
-
class SlicesSocialClient extends BaseClient {
1376
-
readonly slice: SliceSlicesSocialClient;
1377
-
readonly lexicon: LexiconSlicesSocialClient;
1378
-
readonly actor: ActorSlicesSocialClient;
1383
+
class SlicesNetworkClient extends BaseClient {
1384
+
readonly slice: SliceSlicesNetworkClient;
1385
+
readonly lexicon: LexiconSlicesNetworkClient;
1386
+
readonly actor: ActorSlicesNetworkClient;
1379
1387
private readonly sliceUri: string;
1380
1388
1381
1389
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
1382
1390
super(baseUrl, oauthClient);
1383
1391
this.sliceUri = sliceUri;
1384
-
this.slice = new SliceSlicesSocialClient(baseUrl, sliceUri, oauthClient);
1385
-
this.lexicon = new LexiconSlicesSocialClient(
1392
+
this.slice = new SliceSlicesNetworkClient(baseUrl, sliceUri, oauthClient);
1393
+
this.lexicon = new LexiconSlicesNetworkClient(
1386
1394
baseUrl,
1387
1395
sliceUri,
1388
1396
oauthClient
1389
1397
);
1390
-
this.actor = new ActorSlicesSocialClient(baseUrl, sliceUri, oauthClient);
1398
+
this.actor = new ActorSlicesNetworkClient(baseUrl, sliceUri, oauthClient);
1391
1399
}
1392
1400
}
1393
1401
1394
-
class SocialClient extends BaseClient {
1395
-
readonly slices: SlicesSocialClient;
1402
+
class NetworkClient extends BaseClient {
1403
+
readonly slices: SlicesNetworkClient;
1396
1404
private readonly sliceUri: string;
1397
1405
1398
1406
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
1399
1407
super(baseUrl, oauthClient);
1400
1408
this.sliceUri = sliceUri;
1401
-
this.slices = new SlicesSocialClient(baseUrl, sliceUri, oauthClient);
1409
+
this.slices = new SlicesNetworkClient(baseUrl, sliceUri, oauthClient);
1402
1410
}
1403
1411
}
1404
1412
1405
1413
export class AtProtoClient extends BaseClient {
1406
1414
readonly app: AppClient;
1407
-
readonly social: SocialClient;
1415
+
readonly network: NetworkClient;
1408
1416
readonly oauth?: OAuthClient;
1409
1417
private readonly sliceUri: string;
1410
1418
···
1412
1420
super(baseUrl, oauthClient);
1413
1421
this.sliceUri = sliceUri;
1414
1422
this.app = new AppClient(baseUrl, sliceUri, oauthClient);
1415
-
this.social = new SocialClient(baseUrl, sliceUri, oauthClient);
1423
+
this.network = new NetworkClient(baseUrl, sliceUri, oauthClient);
1416
1424
this.oauth = this.oauthClient;
1417
1425
}
1418
1426
1419
1427
async getActors(params: GetActorsParams): Promise<GetActorsResponse> {
1420
1428
const requestParams = { ...params, slice: this.sliceUri };
1421
1429
return await this.makeRequest<GetActorsResponse>(
1422
-
"social.slices.slice.getActors",
1430
+
"network.slices.slice.getActors",
1423
1431
"POST",
1424
1432
requestParams
1425
1433
);
···
1441
1449
slice: this.sliceUri,
1442
1450
};
1443
1451
return await this.makeRequest<SliceRecordsOutput<T>>(
1444
-
"social.slices.slice.getSliceRecords",
1452
+
"network.slices.slice.getSliceRecords",
1445
1453
"POST",
1446
1454
requestParams
1447
1455
);
+1
-1
frontend/src/features/auth/handlers.tsx
+1
-1
frontend/src/features/auth/handlers.tsx
···
132
132
// If we can't find existing records, sync them
133
133
if (!profileCheck.records || profileCheck.records.length === 0) {
134
134
console.log("No existing external collections found, syncing...");
135
-
await atprotoClient.social.slices.slice.syncUserCollections();
135
+
await atprotoClient.network.slices.slice.syncUserCollections();
136
136
} else {
137
137
console.log("External collections already synced, skipping sync");
138
138
}
+15
-13
frontend/src/features/dashboard/handlers.tsx
+15
-13
frontend/src/features/dashboard/handlers.tsx
···
11
11
id: string;
12
12
}
13
13
14
-
async function handleProfilePage(req: Request, params?: URLPatternResult): Promise<Response> {
14
+
async function handleProfilePage(
15
+
req: Request,
16
+
params?: URLPatternResult
17
+
): Promise<Response> {
15
18
const context = await withAuth(req);
16
19
const authResponse = requireAuth(context);
17
20
if (authResponse) return authResponse;
···
22
25
let profileDid: string;
23
26
try {
24
27
const actors = await atprotoClient.getActors({
25
-
where: { handle: { eq: handle } }
28
+
where: { handle: { eq: handle } },
26
29
});
27
-
30
+
28
31
if (actors.actors.length === 0) {
29
32
return new Response("Profile not found", { status: 404 });
30
33
}
31
-
34
+
32
35
profileDid = actors.actors[0].did;
33
36
} catch (error) {
34
37
console.error("Failed to get actor:", error);
···
38
41
// Fetch profile record using the DID
39
42
let _profileRecord;
40
43
try {
41
-
const profileResponse = await atprotoClient.app.bsky.actor.profile.getRecords({
42
-
where: { did: { eq: profileDid } }
43
-
});
44
+
const profileResponse =
45
+
await atprotoClient.app.bsky.actor.profile.getRecords({
46
+
where: { did: { eq: profileDid } },
47
+
});
44
48
_profileRecord = profileResponse.records[0];
45
49
} catch (error) {
46
50
console.error("Failed to fetch profile:", error);
···
50
54
51
55
try {
52
56
// Fetch slices for this DID
53
-
const sliceRecords = await atprotoClient.social.slices.slice.getRecords({
57
+
const sliceRecords = await atprotoClient.network.slices.slice.getRecords({
54
58
where: { did: { eq: profileDid } },
55
59
sortBy: [{ field: "createdAt", direction: "desc" }],
56
60
});
···
117
121
createdAt: new Date().toISOString(),
118
122
};
119
123
120
-
const result = await atprotoClient.social.slices.slice.createRecord(
124
+
const result = await atprotoClient.network.slices.slice.createRecord(
121
125
recordData
122
126
);
123
127
···
135
139
);
136
140
}
137
141
} catch (_error) {
138
-
return renderHTML(
139
-
<CreateSliceDialog error="Failed to create slice" />
140
-
);
142
+
return renderHTML(<CreateSliceDialog error="Failed to create slice" />);
141
143
}
142
144
}
143
145
···
165
167
pattern: new URLPattern({ pathname: "/dialogs/create-slice" }),
166
168
handler: handleCreateSliceDialog,
167
169
},
168
-
];
170
+
];
+9
-9
frontend/src/features/settings/handlers.tsx
+9
-9
frontend/src/features/settings/handlers.tsx
···
28
28
29
29
try {
30
30
const profileRecord =
31
-
await atprotoClient.social.slices.actor.profile.getRecord({
31
+
await atprotoClient.network.slices.actor.profile.getRecord({
32
32
uri: buildAtUri({
33
33
did: context.currentUser.sub!,
34
-
collection: "social.slices.actor.profile",
34
+
collection: "network.slices.actor.profile",
35
35
rkey: "self",
36
36
}),
37
37
});
···
47
47
}
48
48
49
49
return renderHTML(
50
-
<SettingsPage
51
-
profile={profile}
50
+
<SettingsPage
51
+
profile={profile}
52
52
currentUser={context.currentUser}
53
53
updated={updated === "true"}
54
54
error={error}
···
94
94
}
95
95
96
96
const existingProfile =
97
-
await atprotoClient.social.slices.actor.profile.getRecord({
97
+
await atprotoClient.network.slices.actor.profile.getRecord({
98
98
uri: buildAtUri({
99
99
did: context.currentUser.sub,
100
-
collection: "social.slices.actor.profile",
100
+
collection: "network.slices.actor.profile",
101
101
rkey: "self",
102
102
}),
103
103
});
104
104
105
105
if (existingProfile) {
106
-
await atprotoClient.social.slices.actor.profile.updateRecord("self", {
106
+
await atprotoClient.network.slices.actor.profile.updateRecord("self", {
107
107
...profileData,
108
108
createdAt: existingProfile.value.createdAt,
109
109
});
110
110
} else {
111
-
await atprotoClient.social.slices.actor.profile.createRecord(
111
+
await atprotoClient.network.slices.actor.profile.createRecord(
112
112
profileData,
113
113
true
114
114
);
···
137
137
pattern: new URLPattern({ pathname: "/api/profile" }),
138
138
handler: handleUpdateProfile,
139
139
},
140
-
];
140
+
];
+3
-3
frontend/src/features/slices/api-docs/handlers.tsx
+3
-3
frontend/src/features/slices/api-docs/handlers.tsx
···
37
37
try {
38
38
const sliceUri = buildAtUri({
39
39
did: context.currentUser.sub!,
40
-
collection: "social.slices.slice",
40
+
collection: "network.slices.slice",
41
41
rkey: sliceId,
42
42
});
43
43
44
-
const sliceRecord = await atprotoClient.social.slices.slice.getRecord({
44
+
const sliceRecord = await atprotoClient.network.slices.slice.getRecord({
45
45
uri: sliceUri,
46
46
});
47
47
···
67
67
pattern: new URLPattern({ pathname: "/slices/:id/api-docs" }),
68
68
handler: handleSliceApiDocsPage,
69
69
},
70
-
];
70
+
];
+51
-45
frontend/src/features/slices/api-docs/templates/SliceApiDocsPage.tsx
+51
-45
frontend/src/features/slices/api-docs/templates/SliceApiDocsPage.tsx
···
14
14
const baseUrl = Deno.env.get("API_URL") || "http://localhost:3000";
15
15
16
16
// Build the slice URI
17
-
const sliceUri = `at://${currentUser.sub}/social.slices.slice/${sliceId}`;
18
-
const openApiUrl = `${baseUrl}/xrpc/social.slices.slice.openapi?slice=${encodeURIComponent(
17
+
const sliceUri = `at://${currentUser.sub}/network.slices.slice/${sliceId}`;
18
+
const openApiUrl = `${baseUrl}/xrpc/network.slices.slice.openapi?slice=${encodeURIComponent(
19
19
sliceUri
20
20
)}`;
21
21
···
28
28
<script src="https://cdn.tailwindcss.com"></script>
29
29
</head>
30
30
<body class="bg-gray-50 min-h-screen">
31
-
{/* Header with back button */}
32
-
<div class="bg-white border-b border-gray-200 px-4 py-4">
33
-
<div class="max-w-7xl mx-auto flex items-center justify-between">
34
-
<div class="flex items-center">
35
-
<a
36
-
href={`/slices/${sliceId}`}
37
-
class="text-blue-600 hover:text-blue-800 mr-4 flex items-center"
38
-
>
39
-
<svg
40
-
class="w-4 h-4 mr-1"
41
-
fill="none"
42
-
stroke="currentColor"
43
-
viewBox="0 0 24 24"
31
+
{/* Header with back button */}
32
+
<div class="bg-white border-b border-gray-200 px-4 py-4">
33
+
<div class="max-w-7xl mx-auto flex items-center justify-between">
34
+
<div class="flex items-center">
35
+
<a
36
+
href={`/slices/${sliceId}`}
37
+
class="text-blue-600 hover:text-blue-800 mr-4 flex items-center"
44
38
>
45
-
<path
46
-
stroke-linecap="round"
47
-
stroke-linejoin="round"
48
-
stroke-width="2"
49
-
d="M15 19l-7-7 7-7"
50
-
/>
51
-
</svg>
52
-
Back to {sliceName}
53
-
</a>
54
-
</div>
55
-
<div class="text-right">
56
-
<h1 class="text-xl font-semibold text-gray-900">API Documentation</h1>
57
-
<p class="text-gray-600 text-sm">
58
-
Interactive OpenAPI docs for your slice
59
-
</p>
39
+
<svg
40
+
class="w-4 h-4 mr-1"
41
+
fill="none"
42
+
stroke="currentColor"
43
+
viewBox="0 0 24 24"
44
+
>
45
+
<path
46
+
stroke-linecap="round"
47
+
stroke-linejoin="round"
48
+
stroke-width="2"
49
+
d="M15 19l-7-7 7-7"
50
+
/>
51
+
</svg>
52
+
Back to {sliceName}
53
+
</a>
54
+
</div>
55
+
<div class="text-right">
56
+
<h1 class="text-xl font-semibold text-gray-900">
57
+
API Documentation
58
+
</h1>
59
+
<p class="text-gray-600 text-sm">
60
+
Interactive OpenAPI docs for your slice
61
+
</p>
62
+
</div>
60
63
</div>
61
64
</div>
62
-
</div>
63
65
64
66
{/* Info bar */}
65
67
<div class="bg-blue-50 border-b border-blue-200 px-4 py-3">
···
85
87
</div>
86
88
</div>
87
89
88
-
{/* Load Scalar API Reference */}
89
-
<script
90
-
src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"
91
-
async
92
-
></script>
90
+
{/* Load Scalar API Reference */}
91
+
<script
92
+
src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"
93
+
async
94
+
></script>
93
95
94
-
{/* Initialize Scalar when the script loads */}
95
-
<script
96
-
dangerouslySetInnerHTML={{
97
-
__html: `
96
+
{/* Initialize Scalar when the script loads */}
97
+
<script
98
+
dangerouslySetInnerHTML={{
99
+
__html: `
98
100
document.addEventListener('DOMContentLoaded', function() {
99
101
// Wait for Scalar to be available
100
102
const initScalar = () => {
···
119
121
// Alternative approach for parameter defaults
120
122
defaultParameters: {
121
123
slice: '${sliceUri}'
122
-
},${accessToken ? `
124
+
},${
125
+
accessToken
126
+
? `
123
127
authentication: {
124
128
preferredSecurityScheme: 'bearerAuth',
125
129
http: {
···
127
131
token: '${accessToken}'
128
132
}
129
133
}
130
-
},` : ''}
134
+
},`
135
+
: ""
136
+
}
131
137
customCss: \`
132
138
.scalar-api-reference {
133
139
width: 100% !important;
···
181
187
initScalar();
182
188
});
183
189
`,
184
-
}}
185
-
/>
190
+
}}
191
+
/>
186
192
</body>
187
193
</html>
188
194
);
189
-
}
195
+
}
+2
-2
frontend/src/features/slices/codegen/handlers.tsx
+2
-2
frontend/src/features/slices/codegen/handlers.tsx
···
29
29
30
30
try {
31
31
const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId);
32
-
const slice = await atprotoClient.social.slices.slice.getRecord({
32
+
const slice = await atprotoClient.network.slices.slice.getRecord({
33
33
uri: sliceUri,
34
34
});
35
35
···
77
77
const sliceClient = getSliceClient(context, sliceId);
78
78
79
79
// Call the codegen XRPC endpoint
80
-
const result = await sliceClient.social.slices.slice.codegen({
80
+
const result = await sliceClient.network.slices.slice.codegen({
81
81
target: target as string,
82
82
slice: sliceUri,
83
83
});
+4
-4
frontend/src/features/slices/jetstream/handlers.tsx
+4
-4
frontend/src/features/slices/jetstream/handlers.tsx
···
30
30
const sliceClient = getSliceClient(context, sliceId);
31
31
32
32
// Get Jetstream logs
33
-
const result = await sliceClient.social.slices.slice.getJetstreamLogs({
33
+
const result = await sliceClient.network.slices.slice.getJetstreamLogs({
34
34
limit: 100,
35
35
});
36
36
···
82
82
const isCompact = url.searchParams.get("compact") === "true";
83
83
84
84
// Fetch jetstream status using the atproto client
85
-
const data = await atprotoClient.social.slices.slice.getJetstreamStatus();
85
+
const data = await atprotoClient.network.slices.slice.getJetstreamStatus();
86
86
87
87
// Render compact version for logs page
88
88
if (isCompact) {
···
162
162
try {
163
163
const sliceClient = getSliceClient(context, sliceId);
164
164
165
-
const logsResult = await sliceClient.social.slices.slice.getJetstreamLogs({
165
+
const logsResult = await sliceClient.network.slices.slice.getJetstreamLogs({
166
166
limit: 100,
167
167
});
168
168
logs = logsResult.logs.sort(
···
198
198
pattern: new URLPattern({ pathname: "/api/slices/:id/jetstream/logs" }),
199
199
handler: handleJetstreamLogs,
200
200
},
201
-
];
201
+
];
+10
-8
frontend/src/features/slices/lexicon/handlers.tsx
+10
-8
frontend/src/features/slices/lexicon/handlers.tsx
···
26
26
27
27
try {
28
28
const sliceClient = getSliceClient(context, sliceId);
29
-
const lexiconRecords = await sliceClient.social.slices.lexicon.getRecords();
29
+
const lexiconRecords =
30
+
await sliceClient.network.slices.lexicon.getRecords();
30
31
31
32
if (lexiconRecords.records.length === 0) {
32
33
return renderHTML(<EmptyLexiconState />);
···
118
119
};
119
120
120
121
const sliceClient = getSliceClient(context, sliceId);
121
-
const result = await sliceClient.social.slices.lexicon.createRecord(
122
+
const result = await sliceClient.network.slices.lexicon.createRecord(
122
123
lexiconRecord
123
124
);
124
125
···
184
185
185
186
try {
186
187
const sliceClient = getSliceClient(context, sliceId);
187
-
const lexiconRecords = await sliceClient.social.slices.lexicon.getRecords();
188
+
const lexiconRecords =
189
+
await sliceClient.network.slices.lexicon.getRecords();
188
190
189
191
const lexicon = lexiconRecords.records.find((record) =>
190
192
record.uri.endsWith(`/${rkey}`)
···
224
226
225
227
try {
226
228
const sliceClient = getSliceClient(context, sliceId);
227
-
await sliceClient.social.slices.lexicon.deleteRecord(rkey);
229
+
await sliceClient.network.slices.lexicon.deleteRecord(rkey);
228
230
229
231
const remainingLexicons =
230
-
await sliceClient.social.slices.lexicon.getRecords();
232
+
await sliceClient.network.slices.lexicon.getRecords();
231
233
232
234
if (remainingLexicons.records.length === 0) {
233
235
return renderHTML(<EmptyLexiconState withPadding />, {
···
271
273
272
274
const sliceUri = buildAtUri({
273
275
did: context.currentUser.sub!,
274
-
collection: "social.slices.slice",
276
+
collection: "network.slices.slice",
275
277
rkey: sliceId,
276
278
});
277
279
278
280
let slice;
279
281
try {
280
-
slice = await atprotoClient.social.slices.slice.getRecord({
282
+
slice = await atprotoClient.network.slices.slice.getRecord({
281
283
uri: sliceUri,
282
284
});
283
285
} catch (error) {
···
322
324
pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/:rkey" }),
323
325
handler: handleDeleteLexicon,
324
326
},
325
-
];
327
+
];
+1
-1
frontend/src/features/slices/lexicon/templates/SliceLexiconPage.tsx
+1
-1
frontend/src/features/slices/lexicon/templates/SliceLexiconPage.tsx
+9
-9
frontend/src/features/slices/oauth/handlers.tsx
+9
-9
frontend/src/features/slices/oauth/handlers.tsx
···
76
76
77
77
// Register new OAuth client via backend API
78
78
const sliceClient = getSliceClient(context, sliceId);
79
-
const newClient = await sliceClient.social.slices.slice.createOAuthClient({
79
+
const newClient = await sliceClient.network.slices.slice.createOAuthClient({
80
80
clientName,
81
81
redirectUris,
82
82
scope: scope || undefined,
···
119
119
try {
120
120
// Delete OAuth client via backend API
121
121
const sliceClient = getSliceClient(context, sliceId);
122
-
await sliceClient.social.slices.slice.deleteOAuthClient(clientId);
122
+
await sliceClient.network.slices.slice.deleteOAuthClient(clientId);
123
123
124
124
return renderHTML(<OAuthDeleteResult success />);
125
125
} catch (error) {
···
148
148
// Fetch OAuth client details via backend API
149
149
const sliceClient = getSliceClient(context, sliceId);
150
150
const clientsResponse =
151
-
await sliceClient.social.slices.slice.getOAuthClients();
151
+
await sliceClient.network.slices.slice.getOAuthClients();
152
152
const clientData = clientsResponse.clients.find(
153
153
(c) => c.clientId === clientId
154
154
);
155
155
156
156
const sliceUri = buildAtUri({
157
157
did: context.currentUser.sub!,
158
-
collection: "social.slices.slice",
158
+
collection: "network.slices.slice",
159
159
rkey: sliceId,
160
160
});
161
161
···
218
218
// Update OAuth client via backend API
219
219
const sliceClient = getSliceClient(context, sliceId);
220
220
const updatedClient =
221
-
await sliceClient.social.slices.slice.updateOAuthClient({
221
+
await sliceClient.network.slices.slice.updateOAuthClient({
222
222
clientId,
223
223
clientName: clientName || undefined,
224
224
redirectUris: redirectUris.length > 0 ? redirectUris : undefined,
···
269
269
270
270
const sliceUri = buildAtUri({
271
271
did: context.currentUser.sub!,
272
-
collection: "social.slices.slice",
272
+
collection: "network.slices.slice",
273
273
rkey: sliceId,
274
274
});
275
275
···
277
277
278
278
let slice;
279
279
try {
280
-
slice = await atprotoClient.social.slices.slice.getRecord({
280
+
slice = await atprotoClient.network.slices.slice.getRecord({
281
281
uri: sliceUri,
282
282
});
283
283
} catch (error) {
···
296
296
297
297
try {
298
298
const oauthClientsResponse =
299
-
await sliceClient.social.slices.slice.getOAuthClients();
299
+
await sliceClient.network.slices.slice.getOAuthClients();
300
300
console.log("Fetched OAuth clients:", oauthClientsResponse.clients);
301
301
clientsWithDetails = oauthClientsResponse.clients.map((client) => ({
302
302
clientId: client.clientId,
···
351
351
pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri" }),
352
352
handler: handleOAuthClientDelete,
353
353
},
354
-
];
354
+
];
+7
-5
frontend/src/features/slices/overview/handlers.tsx
+7
-5
frontend/src/features/slices/overview/handlers.tsx
···
28
28
if (context.currentUser.isAuthenticated) {
29
29
try {
30
30
const sliceUri = buildAtUri({
31
-
did: context.currentUser.sub || "unknown",
32
-
collection: "social.slices.slice",
31
+
did: context.currentUser.sub!,
32
+
collection: "network.slices.slice",
33
33
rkey: sliceId,
34
34
});
35
35
36
+
console.log(sliceUri);
37
+
36
38
const [sliceRecord, stats] = await Promise.all([
37
-
atprotoClient.social.slices.slice.getRecord({ uri: sliceUri }),
38
-
atprotoClient.social.slices.slice.stats({ slice: sliceUri }),
39
+
atprotoClient.network.slices.slice.getRecord({ uri: sliceUri }),
40
+
atprotoClient.network.slices.slice.stats({ slice: sliceUri }),
39
41
]);
40
42
41
43
const collections = stats.success
···
74
76
pattern: new URLPattern({ pathname: "/slices/:id" }),
75
77
handler: handleSliceOverview,
76
78
},
77
-
];
79
+
];
+6
-9
frontend/src/features/slices/records/handlers.tsx
+6
-9
frontend/src/features/slices/records/handlers.tsx
···
31
31
try {
32
32
const sliceUri = buildAtUri({
33
33
did: context.currentUser.sub ?? "unknown",
34
-
collection: "social.slices.slice",
34
+
collection: "network.slices.slice",
35
35
rkey: sliceId,
36
36
});
37
37
38
38
const [sliceRecord, stats] = await Promise.all([
39
-
atprotoClient.social.slices.slice.getRecord({ uri: sliceUri }),
40
-
atprotoClient.social.slices.slice.stats({ slice: sliceUri }),
39
+
atprotoClient.network.slices.slice.getRecord({ uri: sliceUri }),
40
+
atprotoClient.network.slices.slice.stats({ slice: sliceUri }),
41
41
]);
42
42
43
43
const collections = stats.success
···
68
68
// Fetch real records if a collection is selected
69
69
let records: Array<IndexedRecord & { pretty_value: string }> = [];
70
70
71
-
if (
72
-
(selectedCollection || searchQuery) &&
73
-
sliceData.collections.length > 0
74
-
) {
71
+
if ((selectedCollection || searchQuery) && sliceData.collections.length > 0) {
75
72
try {
76
73
const sliceClient = getSliceClient(context, sliceId);
77
74
const recordsResult =
78
-
await sliceClient.social.slices.slice.getSliceRecords({
75
+
await sliceClient.network.slices.slice.getSliceRecords({
79
76
where: {
80
77
...(selectedCollection && {
81
78
collection: { eq: selectedCollection },
···
122
119
pattern: new URLPattern({ pathname: "/slices/:id/records" }),
123
120
handler: handleSliceRecordsPage,
124
121
},
125
-
];
122
+
];
+4
-4
frontend/src/features/slices/settings/handlers.tsx
+4
-4
frontend/src/features/slices/settings/handlers.tsx
···
34
34
35
35
try {
36
36
const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId);
37
-
const slice = await atprotoClient.social.slices.slice.getRecord({
37
+
const slice = await atprotoClient.network.slices.slice.getRecord({
38
38
uri: sliceUri,
39
39
});
40
40
···
91
91
const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId);
92
92
93
93
// Get the current record first
94
-
const currentRecord = await atprotoClient.social.slices.slice.getRecord({
94
+
const currentRecord = await atprotoClient.network.slices.slice.getRecord({
95
95
uri: sliceUri,
96
96
});
97
97
98
98
// Update the record with new name and domain
99
-
await atprotoClient.social.slices.slice.updateRecord(sliceId, {
99
+
await atprotoClient.network.slices.slice.updateRecord(sliceId, {
100
100
...currentRecord.value,
101
101
name: name.trim(),
102
102
domain: domain.trim(),
···
125
125
126
126
try {
127
127
// Delete the slice record from AT Protocol
128
-
await atprotoClient.social.slices.slice.deleteRecord(sliceId);
128
+
await atprotoClient.network.slices.slice.deleteRecord(sliceId);
129
129
130
130
return hxRedirect("/");
131
131
} catch (_error) {
+4
-4
frontend/src/features/slices/sync-logs/handlers.tsx
+4
-4
frontend/src/features/slices/sync-logs/handlers.tsx
···
27
27
let slice: { name: string } = { name: "Unknown Slice" };
28
28
try {
29
29
const sliceClient = getSliceClient(context, sliceId);
30
-
const sliceRecord = await sliceClient.social.slices.slice.getRecord({
30
+
const sliceRecord = await sliceClient.network.slices.slice.getRecord({
31
31
uri: buildAtUri({
32
32
did: context.currentUser.sub!,
33
-
collection: "social.slices.slice",
33
+
collection: "network.slices.slice",
34
34
rkey: sliceId,
35
35
}),
36
36
});
···
73
73
74
74
try {
75
75
const sliceClient = getSliceClient(context, sliceId);
76
-
const logsResponse = await sliceClient.social.slices.slice.getJobLogs({
76
+
const logsResponse = await sliceClient.network.slices.slice.getJobLogs({
77
77
jobId,
78
78
});
79
79
···
106
106
pattern: new URLPattern({ pathname: "/api/slices/:id/sync/logs/:jobId" }),
107
107
handler: handleSyncJobLogs,
108
108
},
109
-
];
109
+
];
+7
-9
frontend/src/features/slices/sync/handlers.tsx
+7
-9
frontend/src/features/slices/sync/handlers.tsx
···
19
19
const sliceId = params?.pathname.groups.id;
20
20
21
21
if (!sliceId) {
22
-
return renderHTML(
23
-
<SyncResult success={false} error="Invalid slice ID" />
24
-
);
22
+
return renderHTML(<SyncResult success={false} error="Invalid slice ID" />);
25
23
}
26
24
27
25
try {
···
56
54
}
57
55
58
56
const sliceClient = getSliceClient(context, sliceId);
59
-
const syncJobResponse = await sliceClient.social.slices.slice.startSync({
57
+
const syncJobResponse = await sliceClient.network.slices.slice.startSync({
60
58
collections: collections.length > 0 ? collections : undefined,
61
59
externalCollections:
62
60
externalCollections.length > 0 ? externalCollections : undefined,
···
103
101
try {
104
102
const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId);
105
103
const sliceClient = getSliceClient(context, sliceId);
106
-
const jobsResponse = await sliceClient.social.slices.slice.getJobHistory({
104
+
const jobsResponse = await sliceClient.network.slices.slice.getJobHistory({
107
105
userDid: context.currentUser.sub!,
108
106
sliceUri: sliceUri,
109
107
limit: 10,
···
142
140
// Get the slice record
143
141
const sliceUri = buildAtUri({
144
142
did: context.currentUser.sub!,
145
-
collection: "social.slices.slice",
143
+
collection: "network.slices.slice",
146
144
rkey: sliceId,
147
145
});
148
146
···
153
151
const externalCollections: string[] = [];
154
152
155
153
try {
156
-
slice = await atprotoClient.social.slices.slice.getRecord({
154
+
slice = await atprotoClient.network.slices.slice.getRecord({
157
155
uri: sliceUri,
158
156
});
159
157
160
158
// Get all lexicons and filter by record types
161
159
try {
162
160
const lexiconsResponse =
163
-
await sliceClient.social.slices.lexicon.getRecords();
161
+
await sliceClient.network.slices.lexicon.getRecords();
164
162
const recordLexicons = lexiconsResponse.records.filter((lexicon) => {
165
163
try {
166
164
const definitions = JSON.parse(lexicon.value.definitions);
···
215
213
pattern: new URLPattern({ pathname: "/api/slices/:id/job-history" }),
216
214
handler: handleJobHistory,
217
215
},
218
-
];
216
+
];
+10
-10
frontend/src/utils/at-uri.ts
+10
-10
frontend/src/utils/at-uri.ts
···
14
14
* Format: at://did:method:identifier/collection/rkey#fragment
15
15
*/
16
16
export function parseAtUri(uri: string): AtUri {
17
-
if (!uri.startsWith('at://')) {
17
+
if (!uri.startsWith("at://")) {
18
18
throw new Error(`Invalid AT-URI: must start with at:// - got ${uri}`);
19
19
}
20
20
21
21
const withoutProtocol = uri.slice(5); // Remove 'at://'
22
-
const [didAndPath, fragment] = withoutProtocol.split('#');
23
-
const parts = didAndPath.split('/');
22
+
const [didAndPath, fragment] = withoutProtocol.split("#");
23
+
const parts = didAndPath.split("/");
24
24
25
25
if (parts.length < 3) {
26
26
throw new Error(`Invalid AT-URI: missing required parts - got ${uri}`);
···
33
33
}
34
34
35
35
return {
36
-
protocol: 'at',
36
+
protocol: "at",
37
37
did,
38
38
collection,
39
39
rkey,
40
-
fragment
40
+
fragment,
41
41
};
42
42
}
43
43
44
44
/**
45
45
* Build an AT-URI from components
46
46
*/
47
-
export function buildAtUri(components: Omit<AtUri, 'protocol'>): string {
47
+
export function buildAtUri(components: Omit<AtUri, "protocol">): string {
48
48
const { did, collection, rkey, fragment } = components;
49
49
let uri = `at://${did}/${collection}/${rkey}`;
50
-
50
+
51
51
if (fragment) {
52
52
uri += `#${fragment}`;
53
53
}
54
-
54
+
55
55
return uri;
56
56
}
57
57
···
80
80
* Build a slice URI from DID and slice ID
81
81
*/
82
82
export function buildSliceUri(did: string, sliceId: string): string {
83
-
return buildAtUri({ did, collection: "social.slices.slice", rkey: sliceId });
84
-
}
83
+
return buildAtUri({ did, collection: "network.slices.slice", rkey: sliceId });
84
+
}
+8
-5
frontend/src/utils/client.ts
+8
-5
frontend/src/utils/client.ts
···
9
9
}
10
10
11
11
// Helper function to get a slice-specific AT Protocol client
12
-
export function getSliceClient(context: AuthContext, sliceId: string): AtProtoClient {
12
+
export function getSliceClient(
13
+
context: AuthContext,
14
+
sliceId: string
15
+
): AtProtoClient {
13
16
const API_URL = Deno.env.get("API_URL")!;
14
-
17
+
15
18
if (!context.currentUser.sub) {
16
19
throw new Error("User DID is required to create slice client");
17
20
}
18
-
21
+
19
22
const sliceUri = buildAtUri({
20
23
did: context.currentUser.sub,
21
-
collection: "social.slices.slice",
24
+
collection: "network.slices.slice",
22
25
rkey: sliceId,
23
26
});
24
27
return new AtProtoClient(API_URL, sliceUri, atprotoClient.oauth);
25
-
}
28
+
}
+1
-1
lexicons/social/slices/lexicon.json
lexicons/network/slices/lexicon.json
+1
-1
lexicons/social/slices/lexicon.json
lexicons/network/slices/lexicon.json
+1
-1
lexicons/social/slices/profile.json
lexicons/network/slices/profile.json
+1
-1
lexicons/social/slices/profile.json
lexicons/network/slices/profile.json