alpha
Login
or
Join now
zzstoatzz.io
/
zds
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
zig pds
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
Add SQLite-backed PDS record store
author
zzstoatzz
date
2 months ago
(May 20, 2026, 12:06 AM -0500)
commit
f80c18e8
f80c18e80ca1513554200b889f632c2a4c410750
parent
1dd86740
1dd8674069978533c8ee1a35d2c568213ca07c6d
+729
-91
6 changed files
Expand all
Collapse all
Unified
Split
.gitignore
build.zig
build.zig.zon
src
main.zig
server.zig
store.zig
+3
.gitignore
View file
Reviewed
···
4
4
*.db
5
5
*.db-shm
6
6
*.db-wal
7
7
+
*.sqlite3
8
8
+
*.sqlite3-shm
9
9
+
*.sqlite3-wal
7
10
.env
+8
-1
build.zig
View file
Reviewed
···
8
8
.target = target,
9
9
.optimize = optimize,
10
10
});
11
11
+
const zqlite = b.dependency("zqlite", .{
12
12
+
.target = target,
13
13
+
.optimize = optimize,
14
14
+
});
11
15
12
16
const mod = b.addModule("zds", .{
13
17
.root_source_file = b.path("src/root.zig"),
14
18
.target = target,
15
19
.optimize = optimize,
16
16
-
.imports = &.{.{ .name = "zat", .module = zat.module("zat") }},
20
20
+
.imports = &.{
21
21
+
.{ .name = "zat", .module = zat.module("zat") },
22
22
+
.{ .name = "zqlite", .module = zqlite.module("zqlite") },
23
23
+
},
17
24
});
18
25
19
26
const exe = b.addExecutable(.{
+4
build.zig.zon
View file
Reviewed
···
7
7
.zat = .{
8
8
.path = "../../zat.dev/zat",
9
9
},
10
10
+
.zqlite = .{
11
11
+
.url = "git+https://github.com/karlseguin/zqlite.zig?ref=master#05a88d6758753e1c63fdd45b211dde2057094b0c",
12
12
+
.hash = "zqlite-0.0.1-RWLaYz6bmAAT7E_jxopXf-j5Ea8VQldnxsd6TU8sa0Bb",
13
13
+
},
10
14
},
11
15
.paths = .{
12
16
"build.zig",
+11
-1
src/main.zig
View file
Reviewed
···
3
3
4
4
pub fn main(init: std.process.Init) !void {
5
5
var port: u16 = 2583;
6
6
+
var db_path: []const u8 = "dev/zds.sqlite3";
6
7
7
8
var args = std.process.Args.Iterator.init(init.minimal.args);
8
9
_ = args.next();
···
16
17
port = try std.fmt.parseInt(u16, value, 10);
17
18
continue;
18
19
}
20
20
+
if (std.mem.eql(u8, arg, "--db")) {
21
21
+
db_path = args.next() orelse return error.MissingDatabasePath;
22
22
+
continue;
23
23
+
}
19
24
if (std.mem.startsWith(u8, arg, "--port=")) {
20
25
port = try std.fmt.parseInt(u16, arg["--port=".len..], 10);
21
26
continue;
22
27
}
28
28
+
if (std.mem.startsWith(u8, arg, "--db=")) {
29
29
+
db_path = arg["--db=".len..];
30
30
+
continue;
31
31
+
}
23
32
return error.UnknownArgument;
24
33
}
25
34
35
35
+
try zds.store.init(init.io, db_path);
26
36
try zds.server.listen(init.io, .{ .port = port });
27
37
}
28
38
29
39
fn usage() void {
30
40
std.debug.print(
31
31
-
\\usage: zds [--port PORT]
41
41
+
\\usage: zds [--port PORT] [--db PATH]
32
42
\\
33
43
\\Runs a local PDS-shaped HTTP server.
34
44
\\
+368
src/server.zig
View file
Reviewed
···
25
25
repo_get_record,
26
26
repo_list_records,
27
27
repo_delete_record,
28
28
+
repo_apply_writes,
29
29
+
repo_upload_blob,
30
30
+
identity_resolve_handle,
31
31
+
actor_get_profile,
32
32
+
actor_get_profiles,
33
33
+
actor_get_preferences,
34
34
+
actor_put_preferences,
35
35
+
graph_get_follows,
36
36
+
feed_get_timeline,
37
37
+
feed_get_feed,
38
38
+
feed_get_author_feed,
39
39
+
draft_get_drafts,
40
40
+
age_assurance_get_state,
41
41
+
notification_list,
42
42
+
unspecced_get_config,
43
43
+
labeler_get_services,
44
44
+
chat_get_log,
45
45
+
chat_list_convos,
28
46
not_found,
29
47
};
30
48
···
61
79
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.deleteRecord")) {
62
80
return .repo_delete_record;
63
81
}
82
82
+
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.applyWrites")) {
83
83
+
return .repo_apply_writes;
84
84
+
}
85
85
+
if (method == .POST and std.mem.eql(u8, path, "/xrpc/com.atproto.repo.uploadBlob")) {
86
86
+
return .repo_upload_blob;
87
87
+
}
88
88
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/com.atproto.identity.resolveHandle")) {
89
89
+
return .identity_resolve_handle;
90
90
+
}
91
91
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.actor.getProfile")) {
92
92
+
return .actor_get_profile;
93
93
+
}
94
94
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.actor.getProfiles")) {
95
95
+
return .actor_get_profiles;
96
96
+
}
97
97
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.actor.getPreferences")) {
98
98
+
return .actor_get_preferences;
99
99
+
}
100
100
+
if (method == .POST and std.mem.eql(u8, path, "/xrpc/app.bsky.actor.putPreferences")) {
101
101
+
return .actor_put_preferences;
102
102
+
}
103
103
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.graph.getFollows")) {
104
104
+
return .graph_get_follows;
105
105
+
}
106
106
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.feed.getTimeline")) {
107
107
+
return .feed_get_timeline;
108
108
+
}
109
109
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.feed.getFeed")) {
110
110
+
return .feed_get_feed;
111
111
+
}
112
112
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.feed.getAuthorFeed")) {
113
113
+
return .feed_get_author_feed;
114
114
+
}
115
115
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.draft.getDrafts")) {
116
116
+
return .draft_get_drafts;
117
117
+
}
118
118
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.ageassurance.getState")) {
119
119
+
return .age_assurance_get_state;
120
120
+
}
121
121
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.notification.listNotifications")) {
122
122
+
return .notification_list;
123
123
+
}
124
124
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.unspecced.getConfig")) {
125
125
+
return .unspecced_get_config;
126
126
+
}
127
127
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/app.bsky.labeler.getServices")) {
128
128
+
return .labeler_get_services;
129
129
+
}
130
130
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/chat.bsky.convo.getLog")) {
131
131
+
return .chat_get_log;
132
132
+
}
133
133
+
if (method == .GET and std.mem.eql(u8, path, "/xrpc/chat.bsky.convo.listConvos")) {
134
134
+
return .chat_list_convos;
135
135
+
}
64
136
return .not_found;
65
137
}
66
138
···
122
194
.repo_get_record => try repoGetRecord(request),
123
195
.repo_list_records => try repoListRecords(request),
124
196
.repo_delete_record => try repoDeleteRecord(request),
197
197
+
.repo_apply_writes => try repoApplyWrites(request),
198
198
+
.repo_upload_blob => try repoUploadBlob(request),
199
199
+
.identity_resolve_handle => try identityResolveHandle(request),
200
200
+
.actor_get_profile => try actorGetProfile(request),
201
201
+
.actor_get_profiles => try actorGetProfiles(request),
202
202
+
.actor_get_preferences => try actorGetPreferences(request),
203
203
+
.actor_put_preferences => try actorPutPreferences(request),
204
204
+
.graph_get_follows => try authenticatedEmpty(request, "{\"follows\":[]}"),
205
205
+
.feed_get_timeline => try authenticatedEmpty(request, "{\"feed\":[]}"),
206
206
+
.feed_get_feed => try authenticatedEmpty(request, "{\"feed\":[]}"),
207
207
+
.feed_get_author_feed => try feedGetAuthorFeed(request),
208
208
+
.draft_get_drafts => try authenticatedEmpty(request, "{\"drafts\":[]}"),
209
209
+
.age_assurance_get_state => try ageAssuranceGetState(request),
210
210
+
.notification_list => try authenticatedEmpty(request, "{\"notifications\":[],\"seenAt\":\"2026-05-20T00:00:00.000Z\"}"),
211
211
+
.unspecced_get_config => try json(request, .ok, "{\"checkEmailConfirmed\":false,\"liveNow\":{},\"featureFlags\":{}}"),
212
212
+
.labeler_get_services => try json(request, .ok, "{\"views\":[]}"),
213
213
+
.chat_get_log => try authenticatedEmpty(request, "{\"logs\":[],\"cursor\":\"\"}"),
214
214
+
.chat_list_convos => try authenticatedEmpty(request, "{\"convos\":[]}"),
125
215
.not_found => try xrpcError(request, .not_found, "MethodNotImplemented", "Method not implemented"),
126
216
}
127
217
}
···
293
383
};
294
384
295
385
const record = store.get(account.did, collection, rkey) orelse {
386
386
+
if (std.mem.eql(u8, collection, "chat.bsky.actor.declaration") and std.mem.eql(u8, rkey, "self")) {
387
387
+
var body_buf: [512]u8 = undefined;
388
388
+
const uri = try std.fmt.bufPrint(
389
389
+
body_buf[0..160],
390
390
+
"at://{s}/chat.bsky.actor.declaration/self",
391
391
+
.{account.did},
392
392
+
);
393
393
+
const body = try std.fmt.bufPrint(
394
394
+
body_buf[160..],
395
395
+
"{{\"uri\":{f},\"cid\":\"bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454\",\"value\":{{\"$type\":\"chat.bsky.actor.declaration\",\"allowIncoming\":\"all\"}}}}",
396
396
+
.{std.json.fmt(uri, .{})},
397
397
+
);
398
398
+
return json(request, .ok, body);
399
399
+
}
296
400
return xrpcError(request, .not_found, "RecordNotFound", "Record not found");
297
401
};
298
402
const body = try store.writeRecordJson(allocator, record);
···
348
452
return json(request, .ok, "{}");
349
453
}
350
454
455
455
+
fn repoApplyWrites(request: *http.Server.Request) !void {
456
456
+
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
457
457
+
defer arena.deinit();
458
458
+
const allocator = arena.allocator();
459
459
+
460
460
+
const account = requireBearerAccount(request, allocator) catch |err| switch (err) {
461
461
+
error.AuthRequired => return xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
462
462
+
error.InvalidToken => return xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
463
463
+
};
464
464
+
465
465
+
var body_buf: [131072]u8 = undefined;
466
466
+
const body = try readBody(request, &body_buf);
467
467
+
const parsed = try parseJsonBody(request, allocator, body);
468
468
+
const writes = switch (parsed.value) {
469
469
+
.object => |object| object.get("writes") orelse return xrpcError(request, .bad_request, "InvalidRequest", "Missing writes"),
470
470
+
else => return xrpcError(request, .bad_request, "InvalidRequest", "Expected object"),
471
471
+
};
472
472
+
if (writes != .array) return xrpcError(request, .bad_request, "InvalidRequest", "writes must be an array");
473
473
+
474
474
+
var out: std.Io.Writer.Allocating = .init(allocator);
475
475
+
defer out.deinit();
476
476
+
try out.writer.writeAll("{\"commit\":{\"cid\":\"bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454\",\"rev\":\"zds-dev\"},\"results\":[");
477
477
+
for (writes.array.items, 0..) |write, idx| {
478
478
+
if (write != .object) return xrpcError(request, .bad_request, "InvalidRequest", "Invalid write");
479
479
+
const type_name = valueString(write, "$type") orelse {
480
480
+
return xrpcError(request, .bad_request, "InvalidRequest", "Missing write $type");
481
481
+
};
482
482
+
const collection = valueString(write, "collection") orelse {
483
483
+
return xrpcError(request, .bad_request, "InvalidRequest", "Missing collection");
484
484
+
};
485
485
+
if (idx != 0) try out.writer.writeByte(',');
486
486
+
487
487
+
if (std.mem.eql(u8, type_name, "com.atproto.repo.applyWrites#create")) {
488
488
+
const record_value = write.object.get("value") orelse {
489
489
+
return xrpcError(request, .bad_request, "InvalidRequest", "Missing value");
490
490
+
};
491
491
+
const record = store.create(allocator, account, collection, valueString(write, "rkey"), record_value) catch |err| switch (err) {
492
492
+
error.InvalidCollection => return xrpcError(request, .bad_request, "InvalidRequest", "Invalid collection"),
493
493
+
error.InvalidRecordKey => return xrpcError(request, .bad_request, "InvalidRequest", "Invalid rkey"),
494
494
+
else => return err,
495
495
+
};
496
496
+
const uri = try record.uri(allocator);
497
497
+
try out.writer.print(
498
498
+
"{{\"$type\":\"com.atproto.repo.applyWrites#createResult\",\"uri\":{f},\"cid\":{f},\"validationStatus\":\"unknown\"}}",
499
499
+
.{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}) },
500
500
+
);
501
501
+
} else if (std.mem.eql(u8, type_name, "com.atproto.repo.applyWrites#update")) {
502
502
+
const rkey = valueString(write, "rkey") orelse {
503
503
+
return xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey");
504
504
+
};
505
505
+
const record_value = write.object.get("value") orelse {
506
506
+
return xrpcError(request, .bad_request, "InvalidRequest", "Missing value");
507
507
+
};
508
508
+
const record = store.put(account, collection, rkey, record_value) catch |err| switch (err) {
509
509
+
error.InvalidCollection => return xrpcError(request, .bad_request, "InvalidRequest", "Invalid collection"),
510
510
+
error.InvalidRecordKey => return xrpcError(request, .bad_request, "InvalidRequest", "Invalid rkey"),
511
511
+
else => return err,
512
512
+
};
513
513
+
const uri = try record.uri(allocator);
514
514
+
try out.writer.print(
515
515
+
"{{\"$type\":\"com.atproto.repo.applyWrites#updateResult\",\"uri\":{f},\"cid\":{f},\"validationStatus\":\"unknown\"}}",
516
516
+
.{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}) },
517
517
+
);
518
518
+
} else if (std.mem.eql(u8, type_name, "com.atproto.repo.applyWrites#delete")) {
519
519
+
const rkey = valueString(write, "rkey") orelse {
520
520
+
return xrpcError(request, .bad_request, "InvalidRequest", "Missing rkey");
521
521
+
};
522
522
+
store.delete(account.did, collection, rkey);
523
523
+
try out.writer.writeAll("{\"$type\":\"com.atproto.repo.applyWrites#deleteResult\"}");
524
524
+
} else {
525
525
+
return xrpcError(request, .bad_request, "InvalidRequest", "Unknown write operation type");
526
526
+
}
527
527
+
}
528
528
+
try out.writer.writeAll("]}");
529
529
+
return json(request, .ok, out.written());
530
530
+
}
531
531
+
532
532
+
fn repoUploadBlob(request: *http.Server.Request) !void {
533
533
+
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
534
534
+
defer arena.deinit();
535
535
+
const allocator = arena.allocator();
536
536
+
537
537
+
const account = requireBearerAccount(request, allocator) catch |err| switch (err) {
538
538
+
error.AuthRequired => return xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
539
539
+
error.InvalidToken => return xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
540
540
+
};
541
541
+
542
542
+
var body_buf: [2 * 1024 * 1024]u8 = undefined;
543
543
+
const body = try readBody(request, &body_buf);
544
544
+
const mime_type = headerValue(request, "content-type") orelse "application/octet-stream";
545
545
+
const cid = try store.putBlob(allocator, account, body, mime_type);
546
546
+
const body_out = try std.fmt.allocPrint(
547
547
+
allocator,
548
548
+
"{{\"blob\":{{\"$type\":\"blob\",\"ref\":{{\"$link\":{f}}},\"mimeType\":{f},\"size\":{d}}}}}",
549
549
+
.{ std.json.fmt(cid, .{}), std.json.fmt(mime_type, .{}), body.len },
550
550
+
);
551
551
+
return json(request, .ok, body_out);
552
552
+
}
553
553
+
554
554
+
fn identityResolveHandle(request: *http.Server.Request) !void {
555
555
+
var handle_buf: [256]u8 = undefined;
556
556
+
const handle = queryParam(request.head.target, "handle", &handle_buf) orelse {
557
557
+
return xrpcError(request, .bad_request, "InvalidRequest", "Missing handle");
558
558
+
};
559
559
+
const did = if (auth.findAccount(handle)) |account|
560
560
+
account.did
561
561
+
else if (std.ascii.eqlIgnoreCase(handle, "mod-authority.test"))
562
562
+
"did:plc:ar7c4by46qjdydhdevvrndac"
563
563
+
else
564
564
+
return xrpcError(request, .bad_request, "InvalidRequest", "Handle not found");
565
565
+
566
566
+
var body_buf: [320]u8 = undefined;
567
567
+
const body = try std.fmt.bufPrint(&body_buf, "{{\"did\":{f}}}", .{std.json.fmt(did, .{})});
568
568
+
return json(request, .ok, body);
569
569
+
}
570
570
+
571
571
+
fn actorGetProfile(request: *http.Server.Request) !void {
572
572
+
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
573
573
+
defer arena.deinit();
574
574
+
const allocator = arena.allocator();
575
575
+
576
576
+
var actor_buf: [256]u8 = undefined;
577
577
+
const actor = queryParam(request.head.target, "actor", &actor_buf) orelse {
578
578
+
return xrpcError(request, .bad_request, "InvalidRequest", "Missing actor");
579
579
+
};
580
580
+
const account = auth.findAccount(actor) orelse {
581
581
+
return xrpcError(request, .not_found, "ActorNotFound", "Actor not found");
582
582
+
};
583
583
+
584
584
+
const body = try actorProfileJson(allocator, account);
585
585
+
return json(request, .ok, body);
586
586
+
}
587
587
+
588
588
+
fn actorGetProfiles(request: *http.Server.Request) !void {
589
589
+
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
590
590
+
defer arena.deinit();
591
591
+
const allocator = arena.allocator();
592
592
+
593
593
+
var actor_buf: [256]u8 = undefined;
594
594
+
const actor = queryParam(request.head.target, "actors", &actor_buf) orelse {
595
595
+
return xrpcError(request, .bad_request, "InvalidRequest", "Missing actors");
596
596
+
};
597
597
+
const account = auth.findAccount(actor) orelse {
598
598
+
return xrpcError(request, .not_found, "ActorNotFound", "Actor not found");
599
599
+
};
600
600
+
const profile = try actorProfileJson(allocator, account);
601
601
+
const body = try std.fmt.allocPrint(allocator, "{{\"profiles\":[{s}]}}", .{profile});
602
602
+
return json(request, .ok, body);
603
603
+
}
604
604
+
605
605
+
fn actorPutPreferences(request: *http.Server.Request) !void {
606
606
+
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
607
607
+
defer arena.deinit();
608
608
+
const allocator = arena.allocator();
609
609
+
const account = requireBearerAccount(request, allocator) catch |err| switch (err) {
610
610
+
error.AuthRequired => return xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
611
611
+
error.InvalidToken => return xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
612
612
+
};
613
613
+
var body_buf: [65536]u8 = undefined;
614
614
+
const body = try readBody(request, &body_buf);
615
615
+
const parsed = try parseJsonBody(request, allocator, body);
616
616
+
const preferences = switch (parsed.value) {
617
617
+
.object => |object| object.get("preferences") orelse return xrpcError(request, .bad_request, "InvalidRequest", "Missing preferences"),
618
618
+
else => return xrpcError(request, .bad_request, "InvalidRequest", "Expected object"),
619
619
+
};
620
620
+
try store.setPreferences(account, preferences);
621
621
+
return json(request, .ok, "{}");
622
622
+
}
623
623
+
624
624
+
fn feedGetAuthorFeed(request: *http.Server.Request) !void {
625
625
+
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
626
626
+
defer arena.deinit();
627
627
+
const allocator = arena.allocator();
628
628
+
_ = requireBearerAccount(request, allocator) catch |err| switch (err) {
629
629
+
error.AuthRequired => return xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
630
630
+
error.InvalidToken => return xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
631
631
+
};
632
632
+
633
633
+
var actor_buf: [256]u8 = undefined;
634
634
+
const actor = queryParam(request.head.target, "actor", &actor_buf) orelse {
635
635
+
return xrpcError(request, .bad_request, "InvalidRequest", "Missing actor");
636
636
+
};
637
637
+
const account = auth.findAccount(actor) orelse {
638
638
+
return xrpcError(request, .not_found, "ActorNotFound", "Actor not found");
639
639
+
};
640
640
+
var limit_buf: [16]u8 = undefined;
641
641
+
const limit = if (queryParam(request.head.target, "limit", &limit_buf)) |raw|
642
642
+
std.fmt.parseInt(usize, raw, 10) catch 50
643
643
+
else
644
644
+
50;
645
645
+
const body = try store.writeAuthorFeedJson(allocator, account.did, account.handle, @min(limit, 100));
646
646
+
return json(request, .ok, body);
647
647
+
}
648
648
+
649
649
+
fn actorGetPreferences(request: *http.Server.Request) !void {
650
650
+
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
651
651
+
defer arena.deinit();
652
652
+
const allocator = arena.allocator();
653
653
+
const account = requireBearerAccount(request, allocator) catch |err| switch (err) {
654
654
+
error.AuthRequired => return xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
655
655
+
error.InvalidToken => return xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
656
656
+
};
657
657
+
const body = try std.fmt.allocPrint(allocator, "{{\"preferences\":{s}}}", .{store.getPreferences(account)});
658
658
+
return json(request, .ok, body);
659
659
+
}
660
660
+
661
661
+
fn ageAssuranceGetState(request: *http.Server.Request) !void {
662
662
+
return authenticatedEmpty(
663
663
+
request,
664
664
+
"{\"state\":{\"status\":\"assured\",\"access\":\"full\",\"lastInitiatedAt\":null},\"metadata\":{\"accountCreatedAt\":null}}",
665
665
+
);
666
666
+
}
667
667
+
668
668
+
fn authenticatedEmpty(request: *http.Server.Request, body: []const u8) !void {
669
669
+
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
670
670
+
defer arena.deinit();
671
671
+
_ = requireBearerAccount(request, arena.allocator()) catch |err| switch (err) {
672
672
+
error.AuthRequired => return xrpcError(request, .unauthorized, "AuthenticationRequired", "Authentication required"),
673
673
+
error.InvalidToken => return xrpcError(request, .unauthorized, "InvalidToken", "Invalid token"),
674
674
+
};
675
675
+
return json(request, .ok, body);
676
676
+
}
677
677
+
351
678
fn requireBearerAccount(request: *const http.Server.Request, allocator: std.mem.Allocator) !auth.Account {
352
679
const auth_header = headerValue(request, "authorization") orelse {
353
680
return error.AuthRequired;
···
374
701
};
375
702
}
376
703
704
704
+
fn valueString(value: std.json.Value, key: []const u8) ?[]const u8 {
705
705
+
return switch (value) {
706
706
+
.object => |object| switch (object.get(key) orelse return null) {
707
707
+
.string => |string| string,
708
708
+
else => null,
709
709
+
},
710
710
+
else => null,
711
711
+
};
712
712
+
}
713
713
+
377
714
fn writeRecordRef(request: *http.Server.Request, allocator: std.mem.Allocator, record: store.Record) !void {
378
715
const uri = try record.uri(allocator);
379
716
const body_out = try std.fmt.allocPrint(
···
382
719
.{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}) },
383
720
);
384
721
return json(request, .ok, body_out);
722
722
+
}
723
723
+
724
724
+
fn actorProfileJson(allocator: std.mem.Allocator, account: auth.Account) ![]const u8 {
725
725
+
const display_name, const description = profileText(allocator, account) catch .{ account.handle, "" };
726
726
+
const posts_count = store.count(account.did, "app.bsky.feed.post");
727
727
+
const follows_count = store.count(account.did, "app.bsky.graph.follow");
728
728
+
const followers_count = store.countSubject("app.bsky.graph.follow", account.did);
729
729
+
return std.fmt.allocPrint(
730
730
+
allocator,
731
731
+
"{{\"did\":{f},\"handle\":{f},\"displayName\":{f},\"description\":{f},\"postsCount\":{d},\"followersCount\":{d},\"followsCount\":{d},\"viewer\":{{\"muted\":false,\"blockedBy\":false}}}}",
732
732
+
.{
733
733
+
std.json.fmt(account.did, .{}),
734
734
+
std.json.fmt(account.handle, .{}),
735
735
+
std.json.fmt(display_name, .{}),
736
736
+
std.json.fmt(description, .{}),
737
737
+
posts_count,
738
738
+
followers_count,
739
739
+
follows_count,
740
740
+
},
741
741
+
);
742
742
+
}
743
743
+
744
744
+
fn profileText(allocator: std.mem.Allocator, account: auth.Account) !struct { []const u8, []const u8 } {
745
745
+
const profile = store.get(account.did, "app.bsky.actor.profile", "self") orelse return .{ account.handle, "" };
746
746
+
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, profile.value_json, .{});
747
747
+
const display_name = zat.json.getString(parsed.value, "displayName") orelse account.handle;
748
748
+
const description = zat.json.getString(parsed.value, "description") orelse "";
749
749
+
return .{ display_name, description };
385
750
}
386
751
387
752
fn readBody(request: *http.Server.Request, buf: []u8) ![]const u8 {
···
494
859
try std.testing.expectEqual(Route.get_service_auth, route(.GET, "/xrpc/com.atproto.server.getServiceAuth?aud=did%3Aplc%3Aservice"));
495
860
try std.testing.expectEqual(Route.repo_list_records, route(.GET, "/xrpc/com.atproto.repo.listRecords?repo=alice.test&collection=app.bsky.feed.post"));
496
861
try std.testing.expectEqual(Route.repo_create_record, route(.POST, "/xrpc/com.atproto.repo.createRecord"));
862
862
+
try std.testing.expectEqual(Route.repo_upload_blob, route(.POST, "/xrpc/com.atproto.repo.uploadBlob"));
863
863
+
try std.testing.expectEqual(Route.age_assurance_get_state, route(.GET, "/xrpc/app.bsky.ageassurance.getState?countryCode=US"));
864
864
+
try std.testing.expectEqual(Route.identity_resolve_handle, route(.GET, "/xrpc/com.atproto.identity.resolveHandle?handle=alice.test"));
497
865
}
498
866
499
867
test "does not route wrong methods" {
+335
-89
src/store.zig
View file
Reviewed
···
1
1
const std = @import("std");
2
2
const auth = @import("auth.zig");
3
3
const zat = @import("zat");
4
4
-
5
5
-
const max_records = 4096;
4
4
+
const zqlite = @import("zqlite");
5
5
+
const Io = std.Io;
6
6
7
7
pub const Error = error{
8
8
InvalidCollection,
9
9
InvalidRecordKey,
10
10
MissingRecord,
11
11
-
StoreFull,
11
11
+
StoreNotInitialized,
12
12
};
13
13
14
14
pub const Record = struct {
15
15
-
active: bool = false,
16
16
-
did: []const u8 = "",
17
17
-
collection: []const u8 = "",
18
18
-
rkey: []const u8 = "",
19
19
-
cid: []const u8 = "",
20
20
-
value_json: []const u8 = "",
21
21
-
seq: u64 = 0,
15
15
+
did: []const u8,
16
16
+
collection: []const u8,
17
17
+
rkey: []const u8,
18
18
+
cid: []const u8,
19
19
+
value_json: []const u8,
20
20
+
seq: u64,
22
21
23
22
pub fn uri(self: Record, allocator: std.mem.Allocator) ![]const u8 {
24
23
return std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ self.did, self.collection, self.rkey });
25
24
}
26
25
};
27
26
28
28
-
var records: [max_records]Record = [_]Record{.{}} ** max_records;
29
29
-
var next_seq: u64 = 1;
27
27
+
var conn: zqlite.Conn = undefined;
28
28
+
var initialized = false;
29
29
+
var store_io: Io = undefined;
30
30
+
var mutex: Io.Mutex = .init;
31
31
+
32
32
+
pub fn init(io: Io, path: []const u8) !void {
33
33
+
if (initialized) return;
34
34
+
store_io = io;
35
35
+
if (!std.mem.eql(u8, path, ":memory:")) {
36
36
+
if (std.fs.path.dirname(path)) |dir| try Io.Dir.createDirPath(.cwd(), io, dir);
37
37
+
}
38
38
+
39
39
+
const path_z = try std.heap.page_allocator.dupeZ(u8, path);
40
40
+
conn = try zqlite.open(path_z.ptr, zqlite.OpenFlags.Create | zqlite.OpenFlags.ReadWrite);
41
41
+
initialized = true;
42
42
+
errdefer close();
43
43
+
44
44
+
try conn.busyTimeout(5000);
45
45
+
try conn.execNoArgs("PRAGMA journal_mode=WAL");
46
46
+
try conn.execNoArgs("PRAGMA foreign_keys=ON");
47
47
+
try migrate();
48
48
+
try seedAccounts();
49
49
+
}
50
50
+
51
51
+
pub fn close() void {
52
52
+
if (!initialized) return;
53
53
+
conn.close();
54
54
+
initialized = false;
55
55
+
}
30
56
31
57
pub fn resolveRepo(repo: []const u8) ?auth.Account {
32
58
return auth.findAccount(repo);
···
53
79
if (zat.Rkey.parse(rkey) == null) return Error.InvalidRecordKey;
54
80
55
81
const value_json = try stringifyValue(std.heap.page_allocator, value);
56
56
-
const cid = try std.fmt.allocPrint(std.heap.page_allocator, "bafyreidzds{d}", .{next_seq});
57
82
58
58
-
if (findIndex(account.did, collection, rkey)) |idx| {
59
59
-
records[idx] = .{
60
60
-
.active = true,
61
61
-
.did = records[idx].did,
62
62
-
.collection = records[idx].collection,
63
63
-
.rkey = records[idx].rkey,
64
64
-
.cid = cid,
65
65
-
.value_json = value_json,
66
66
-
.seq = next_seq,
67
67
-
};
68
68
-
next_seq += 1;
69
69
-
return records[idx];
70
70
-
}
83
83
+
mutex.lockUncancelable(store_io);
84
84
+
defer mutex.unlock(store_io);
85
85
+
try requireInitialized();
86
86
+
87
87
+
const seq = try nextSeqLocked();
88
88
+
const cid = try cidForJson(std.heap.page_allocator, value_json);
89
89
+
const uri = try std.fmt.allocPrint(std.heap.page_allocator, "at://{s}/{s}/{s}", .{ account.did, collection, rkey });
90
90
+
const rev = try std.fmt.allocPrint(std.heap.page_allocator, "zds-{d}", .{seq});
91
91
+
92
92
+
try conn.exclusiveTransaction();
93
93
+
errdefer conn.rollback();
94
94
+
try conn.exec(
95
95
+
\\INSERT INTO records (did, collection, rkey, uri, cid, value_json, rev, seq)
96
96
+
\\VALUES (?, ?, ?, ?, ?, ?, ?, ?)
97
97
+
\\ON CONFLICT(did, collection, rkey) DO UPDATE SET
98
98
+
\\ uri = excluded.uri,
99
99
+
\\ cid = excluded.cid,
100
100
+
\\ value_json = excluded.value_json,
101
101
+
\\ rev = excluded.rev,
102
102
+
\\ seq = excluded.seq
103
103
+
, .{ account.did, collection, rkey, uri, cid, value_json, rev, @as(i64, @intCast(seq)) });
104
104
+
try conn.exec(
105
105
+
\\INSERT INTO commits (seq, did, cid, rev, prev)
106
106
+
\\VALUES (?, ?, ?, ?, (SELECT cid FROM commits WHERE did = ? ORDER BY seq DESC LIMIT 1))
107
107
+
, .{ @as(i64, @intCast(seq)), account.did, cid, rev, account.did });
108
108
+
try conn.commit();
71
109
72
72
-
const idx = freeIndex() orelse return Error.StoreFull;
73
73
-
records[idx] = .{
74
74
-
.active = true,
110
110
+
return .{
75
111
.did = try std.heap.page_allocator.dupe(u8, account.did),
76
112
.collection = try std.heap.page_allocator.dupe(u8, collection),
77
113
.rkey = try std.heap.page_allocator.dupe(u8, rkey),
78
114
.cid = cid,
79
115
.value_json = value_json,
80
80
-
.seq = next_seq,
116
116
+
.seq = seq,
81
117
};
82
82
-
next_seq += 1;
83
83
-
return records[idx];
84
118
}
85
119
86
120
pub fn delete(did: []const u8, collection: []const u8, rkey: []const u8) void {
87
87
-
if (findIndex(did, collection, rkey)) |idx| {
88
88
-
records[idx].active = false;
89
89
-
}
121
121
+
mutex.lockUncancelable(store_io);
122
122
+
defer mutex.unlock(store_io);
123
123
+
requireInitialized() catch return;
124
124
+
conn.exec("DELETE FROM records WHERE did = ? AND collection = ? AND rkey = ?", .{ did, collection, rkey }) catch {};
90
125
}
91
126
92
127
pub fn get(did: []const u8, collection: []const u8, rkey: []const u8) ?Record {
93
93
-
if (findIndex(did, collection, rkey)) |idx| return records[idx];
94
94
-
return null;
128
128
+
mutex.lockUncancelable(store_io);
129
129
+
defer mutex.unlock(store_io);
130
130
+
requireInitialized() catch return null;
131
131
+
132
132
+
const row = conn.row(
133
133
+
\\SELECT did, collection, rkey, cid, value_json, seq
134
134
+
\\FROM records
135
135
+
\\WHERE did = ? AND collection = ? AND rkey = ?
136
136
+
, .{ did, collection, rkey }) catch return null;
137
137
+
if (row == null) return null;
138
138
+
defer row.?.deinit();
139
139
+
return recordFromRow(row.?, std.heap.page_allocator) catch null;
140
140
+
}
141
141
+
142
142
+
pub fn count(did: []const u8, collection: []const u8) usize {
143
143
+
mutex.lockUncancelable(store_io);
144
144
+
defer mutex.unlock(store_io);
145
145
+
requireInitialized() catch return 0;
146
146
+
const row = conn.row("SELECT count(*) FROM records WHERE did = ? AND collection = ?", .{ did, collection }) catch return 0;
147
147
+
if (row == null) return 0;
148
148
+
defer row.?.deinit();
149
149
+
return @intCast(row.?.int(0));
150
150
+
}
151
151
+
152
152
+
pub fn countSubject(collection: []const u8, subject_did: []const u8) usize {
153
153
+
mutex.lockUncancelable(store_io);
154
154
+
defer mutex.unlock(store_io);
155
155
+
requireInitialized() catch return 0;
156
156
+
const row = conn.row("SELECT count(*) FROM records WHERE collection = ? AND instr(value_json, ?) > 0", .{ collection, subject_did }) catch return 0;
157
157
+
if (row == null) return 0;
158
158
+
defer row.?.deinit();
159
159
+
return @intCast(row.?.int(0));
160
160
+
}
161
161
+
162
162
+
pub fn setPreferences(account: auth.Account, value: std.json.Value) !void {
163
163
+
const value_json = try stringifyValue(std.heap.page_allocator, value);
164
164
+
mutex.lockUncancelable(store_io);
165
165
+
defer mutex.unlock(store_io);
166
166
+
try requireInitialized();
167
167
+
try conn.exec(
168
168
+
\\INSERT INTO preferences (did, value_json)
169
169
+
\\VALUES (?, ?)
170
170
+
\\ON CONFLICT(did) DO UPDATE SET value_json = excluded.value_json
171
171
+
, .{ account.did, value_json });
172
172
+
}
173
173
+
174
174
+
pub fn getPreferences(account: auth.Account) []const u8 {
175
175
+
mutex.lockUncancelable(store_io);
176
176
+
defer mutex.unlock(store_io);
177
177
+
requireInitialized() catch return "[]";
178
178
+
const row = conn.row("SELECT value_json FROM preferences WHERE did = ?", .{account.did}) catch return "[]";
179
179
+
if (row == null) return "[]";
180
180
+
defer row.?.deinit();
181
181
+
return std.heap.page_allocator.dupe(u8, row.?.text(0)) catch "[]";
182
182
+
}
183
183
+
184
184
+
pub fn putBlob(
185
185
+
allocator: std.mem.Allocator,
186
186
+
account: auth.Account,
187
187
+
data: []const u8,
188
188
+
mime_type: []const u8,
189
189
+
) ![]const u8 {
190
190
+
const cid = try cidForBlob(allocator, data);
191
191
+
192
192
+
mutex.lockUncancelable(store_io);
193
193
+
defer mutex.unlock(store_io);
194
194
+
try requireInitialized();
195
195
+
try conn.exec(
196
196
+
\\INSERT INTO blobs (cid, did, mime_type, size)
197
197
+
\\VALUES (?, ?, ?, ?)
198
198
+
\\ON CONFLICT(cid) DO UPDATE SET
199
199
+
\\ did = excluded.did,
200
200
+
\\ mime_type = excluded.mime_type,
201
201
+
\\ size = excluded.size
202
202
+
, .{ cid, account.did, mime_type, @as(i64, @intCast(data.len)) });
203
203
+
return cid;
95
204
}
96
205
97
206
pub fn writeRecordJson(allocator: std.mem.Allocator, record: Record) ![]const u8 {
···
113
222
collection: []const u8,
114
223
limit: usize,
115
224
) ![]const u8 {
225
225
+
mutex.lockUncancelable(store_io);
226
226
+
defer mutex.unlock(store_io);
227
227
+
try requireInitialized();
228
228
+
229
229
+
var rows = try conn.rows(
230
230
+
\\SELECT did, collection, rkey, cid, value_json, seq
231
231
+
\\FROM records
232
232
+
\\WHERE did = ? AND collection = ?
233
233
+
\\ORDER BY seq DESC
234
234
+
\\LIMIT ?
235
235
+
, .{ did, collection, @as(i64, @intCast(if (limit == 0) 100 else limit)) });
236
236
+
defer rows.deinit();
237
237
+
116
238
var out: std.Io.Writer.Allocating = .init(allocator);
117
239
defer out.deinit();
118
118
-
119
240
try out.writer.writeAll("{\"records\":[");
120
120
-
var remaining = if (limit == 0) max_records else limit;
121
241
var first = true;
122
122
-
var before: ?u64 = null;
123
123
-
while (remaining > 0) {
124
124
-
const maybe_record = newestBefore(did, collection, before);
125
125
-
const record = maybe_record orelse break;
242
242
+
while (rows.next()) |row| {
243
243
+
const record = try recordFromRow(row, std.heap.page_allocator);
126
244
if (!first) try out.writer.writeByte(',');
127
245
first = false;
128
128
-
129
246
const uri = try record.uri(allocator);
130
247
defer allocator.free(uri);
131
248
try out.writer.print(
132
249
"{{\"uri\":{f},\"cid\":{f},\"value\":{s}}}",
133
250
.{ std.json.fmt(uri, .{}), std.json.fmt(record.cid, .{}), record.value_json },
134
251
);
135
135
-
before = record.seq;
136
136
-
remaining -= 1;
137
252
}
253
253
+
if (rows.err) |err| return err;
138
254
try out.writer.writeAll("]}");
139
255
return out.toOwnedSlice();
140
256
}
141
257
142
142
-
fn stringifyValue(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 {
258
258
+
pub fn writeAuthorFeedJson(
259
259
+
allocator: std.mem.Allocator,
260
260
+
did: []const u8,
261
261
+
handle: []const u8,
262
262
+
limit: usize,
263
263
+
) ![]const u8 {
264
264
+
mutex.lockUncancelable(store_io);
265
265
+
defer mutex.unlock(store_io);
266
266
+
try requireInitialized();
267
267
+
268
268
+
var rows = try conn.rows(
269
269
+
\\SELECT did, collection, rkey, cid, value_json, seq
270
270
+
\\FROM records
271
271
+
\\WHERE did = ? AND collection = 'app.bsky.feed.post'
272
272
+
\\ORDER BY seq DESC
273
273
+
\\LIMIT ?
274
274
+
, .{ did, @as(i64, @intCast(if (limit == 0) 100 else limit)) });
275
275
+
defer rows.deinit();
276
276
+
143
277
var out: std.Io.Writer.Allocating = .init(allocator);
144
278
defer out.deinit();
145
145
-
try out.writer.print("{f}", .{std.json.fmt(value, .{})});
279
279
+
try out.writer.writeAll("{\"feed\":[");
280
280
+
var first = true;
281
281
+
while (rows.next()) |row| {
282
282
+
const record = try recordFromRow(row, std.heap.page_allocator);
283
283
+
if (!first) try out.writer.writeByte(',');
284
284
+
first = false;
285
285
+
const uri = try record.uri(allocator);
286
286
+
defer allocator.free(uri);
287
287
+
try out.writer.print(
288
288
+
"{{\"post\":{{\"uri\":{f},\"cid\":{f},\"author\":{{\"did\":{f},\"handle\":{f},\"displayName\":{f}}},\"record\":{s},\"replyCount\":0,\"repostCount\":0,\"likeCount\":0,\"quoteCount\":0,\"indexedAt\":\"2026-05-20T00:00:00.000Z\",\"viewer\":{{}}}}}}",
289
289
+
.{
290
290
+
std.json.fmt(uri, .{}),
291
291
+
std.json.fmt(record.cid, .{}),
292
292
+
std.json.fmt(did, .{}),
293
293
+
std.json.fmt(handle, .{}),
294
294
+
std.json.fmt(handle, .{}),
295
295
+
record.value_json,
296
296
+
},
297
297
+
);
298
298
+
}
299
299
+
if (rows.err) |err| return err;
300
300
+
try out.writer.writeAll("]}");
146
301
return out.toOwnedSlice();
147
302
}
148
303
149
149
-
fn nextRkey(allocator: std.mem.Allocator) ![]const u8 {
150
150
-
return std.fmt.allocPrint(allocator, "3zds{d}", .{next_seq});
304
304
+
fn migrate() !void {
305
305
+
inline for (schema_statements) |sql| try conn.execNoArgs(sql);
151
306
}
152
307
153
153
-
fn findIndex(did: []const u8, collection: []const u8, rkey: []const u8) ?usize {
154
154
-
for (records, 0..) |record, idx| {
155
155
-
if (!record.active) continue;
156
156
-
if (std.mem.eql(u8, record.did, did) and
157
157
-
std.mem.eql(u8, record.collection, collection) and
158
158
-
std.mem.eql(u8, record.rkey, rkey))
159
159
-
{
160
160
-
return idx;
161
161
-
}
308
308
+
fn seedAccounts() !void {
309
309
+
for (auth.accounts) |account| {
310
310
+
try conn.exec(
311
311
+
\\INSERT INTO accounts (did, handle, password_hash)
312
312
+
\\VALUES (?, ?, ?)
313
313
+
\\ON CONFLICT(did) DO UPDATE SET
314
314
+
\\ handle = excluded.handle,
315
315
+
\\ password_hash = excluded.password_hash
316
316
+
, .{ account.did, account.handle, account.password });
162
317
}
163
163
-
return null;
164
318
}
165
319
166
166
-
fn freeIndex() ?usize {
167
167
-
for (records, 0..) |record, idx| {
168
168
-
if (!record.active) return idx;
169
169
-
}
170
170
-
return null;
320
320
+
fn nextSeqLocked() !u64 {
321
321
+
const row = try conn.row("SELECT COALESCE(MAX(seq), 0) + 1 FROM commits", .{});
322
322
+
if (row == null) return 1;
323
323
+
defer row.?.deinit();
324
324
+
return @intCast(row.?.int(0));
171
325
}
172
326
173
173
-
fn newestBefore(did: []const u8, collection: []const u8, before: ?u64) ?Record {
174
174
-
var best: ?Record = null;
175
175
-
for (records) |record| {
176
176
-
if (!record.active) continue;
177
177
-
if (!std.mem.eql(u8, record.did, did)) continue;
178
178
-
if (!std.mem.eql(u8, record.collection, collection)) continue;
179
179
-
if (before) |seq| {
180
180
-
if (record.seq >= seq) continue;
181
181
-
}
182
182
-
if (best == null or record.seq > best.?.seq) best = record;
183
183
-
}
184
184
-
return best;
327
327
+
fn nextRkey(allocator: std.mem.Allocator) ![]const u8 {
328
328
+
mutex.lockUncancelable(store_io);
329
329
+
defer mutex.unlock(store_io);
330
330
+
try requireInitialized();
331
331
+
return std.fmt.allocPrint(allocator, "3zds{d}", .{try nextSeqLocked()});
185
332
}
186
333
187
187
-
test "stores and lists records newest first" {
188
188
-
const alice = auth.findAccount("alice.test").?;
189
189
-
const one = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"text\":\"one\"}", .{});
190
190
-
defer one.deinit();
191
191
-
const two = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"text\":\"two\"}", .{});
192
192
-
defer two.deinit();
334
334
+
fn recordFromRow(row: zqlite.Row, allocator: std.mem.Allocator) !Record {
335
335
+
return .{
336
336
+
.did = try allocator.dupe(u8, row.text(0)),
337
337
+
.collection = try allocator.dupe(u8, row.text(1)),
338
338
+
.rkey = try allocator.dupe(u8, row.text(2)),
339
339
+
.cid = try allocator.dupe(u8, row.text(3)),
340
340
+
.value_json = try allocator.dupe(u8, row.text(4)),
341
341
+
.seq = @intCast(row.int(5)),
342
342
+
};
343
343
+
}
193
344
194
194
-
_ = try create(std.testing.allocator, alice, "app.bsky.feed.post", "a", one.value);
195
195
-
_ = try create(std.testing.allocator, alice, "app.bsky.feed.post", "b", two.value);
196
196
-
const body = try writeListJson(std.testing.allocator, alice.did, "app.bsky.feed.post", 10);
197
197
-
defer std.testing.allocator.free(body);
345
345
+
fn stringifyValue(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 {
346
346
+
var out: std.Io.Writer.Allocating = .init(allocator);
347
347
+
defer out.deinit();
348
348
+
try out.writer.print("{f}", .{std.json.fmt(value, .{})});
349
349
+
return out.toOwnedSlice();
350
350
+
}
351
351
+
352
352
+
fn cidForJson(allocator: std.mem.Allocator, json: []const u8) ![]const u8 {
353
353
+
const cid = try zat.cbor.Cid.forDagCbor(allocator, json);
354
354
+
defer allocator.free(cid.raw);
355
355
+
return zat.multibase.base32lower.encode(allocator, cid.raw);
356
356
+
}
357
357
+
358
358
+
fn cidForBlob(allocator: std.mem.Allocator, data: []const u8) ![]const u8 {
359
359
+
const cid = try zat.cbor.Cid.create(allocator, 1, 0x55, 0x12, data);
360
360
+
defer allocator.free(cid.raw);
361
361
+
return zat.multibase.base32lower.encode(allocator, cid.raw);
362
362
+
}
363
363
+
364
364
+
fn requireInitialized() !void {
365
365
+
if (!initialized) return Error.StoreNotInitialized;
366
366
+
}
198
367
199
199
-
try std.testing.expect(std.mem.indexOf(u8, body, "\"text\":\"two\"").? <
200
200
-
std.mem.indexOf(u8, body, "\"text\":\"one\"").?);
368
368
+
const schema_statements = [_][*:0]const u8{
369
369
+
\\CREATE TABLE IF NOT EXISTS accounts (
370
370
+
\\ did TEXT PRIMARY KEY,
371
371
+
\\ handle TEXT NOT NULL UNIQUE,
372
372
+
\\ password_hash TEXT NOT NULL,
373
373
+
\\ created_at INTEGER NOT NULL DEFAULT (unixepoch())
374
374
+
\\)
375
375
+
,
376
376
+
\\CREATE TABLE IF NOT EXISTS records (
377
377
+
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
378
378
+
\\ collection TEXT NOT NULL,
379
379
+
\\ rkey TEXT NOT NULL,
380
380
+
\\ uri TEXT NOT NULL UNIQUE,
381
381
+
\\ cid TEXT NOT NULL,
382
382
+
\\ value_json BLOB NOT NULL,
383
383
+
\\ rev TEXT NOT NULL,
384
384
+
\\ seq INTEGER NOT NULL,
385
385
+
\\ PRIMARY KEY (did, collection, rkey)
386
386
+
\\)
387
387
+
,
388
388
+
"CREATE INDEX IF NOT EXISTS records_collection_idx ON records (did, collection, seq DESC)",
389
389
+
"CREATE INDEX IF NOT EXISTS records_cid_idx ON records (cid)",
390
390
+
\\CREATE TABLE IF NOT EXISTS commits (
391
391
+
\\ seq INTEGER PRIMARY KEY,
392
392
+
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
393
393
+
\\ cid TEXT NOT NULL,
394
394
+
\\ rev TEXT NOT NULL,
395
395
+
\\ prev TEXT
396
396
+
\\)
397
397
+
,
398
398
+
"CREATE INDEX IF NOT EXISTS commits_did_idx ON commits (did, seq DESC)",
399
399
+
\\CREATE TABLE IF NOT EXISTS seq_events (
400
400
+
\\ seq INTEGER PRIMARY KEY,
401
401
+
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
402
402
+
\\ commit_cid TEXT NOT NULL,
403
403
+
\\ evt BLOB NOT NULL
404
404
+
\\)
405
405
+
,
406
406
+
\\CREATE TABLE IF NOT EXISTS blocks (
407
407
+
\\ cid TEXT PRIMARY KEY,
408
408
+
\\ data BLOB NOT NULL
409
409
+
\\)
410
410
+
,
411
411
+
\\CREATE TABLE IF NOT EXISTS blobs (
412
412
+
\\ cid TEXT PRIMARY KEY,
413
413
+
\\ did TEXT NOT NULL REFERENCES accounts(did) ON DELETE CASCADE,
414
414
+
\\ mime_type TEXT NOT NULL,
415
415
+
\\ size INTEGER NOT NULL,
416
416
+
\\ storage TEXT NOT NULL DEFAULT 'local',
417
417
+
\\ created_at INTEGER NOT NULL DEFAULT (unixepoch())
418
418
+
\\)
419
419
+
,
420
420
+
\\CREATE TABLE IF NOT EXISTS record_blobs (
421
421
+
\\ blob_cid TEXT NOT NULL REFERENCES blobs(cid) ON DELETE CASCADE,
422
422
+
\\ record_uri TEXT NOT NULL REFERENCES records(uri) ON DELETE CASCADE,
423
423
+
\\ PRIMARY KEY (blob_cid, record_uri)
424
424
+
\\)
425
425
+
,
426
426
+
\\CREATE TABLE IF NOT EXISTS preferences (
427
427
+
\\ did TEXT PRIMARY KEY REFERENCES accounts(did) ON DELETE CASCADE,
428
428
+
\\ value_json BLOB NOT NULL
429
429
+
\\)
430
430
+
,
431
431
+
};
432
432
+
433
433
+
test "persists records in sqlite" {
434
434
+
try init(std.Options.debug_io, ":memory:");
435
435
+
defer close();
436
436
+
437
437
+
const account = auth.findAccount("alice.test").?;
438
438
+
const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"text\":\"hello\"}", .{});
439
439
+
defer parsed.deinit();
440
440
+
441
441
+
const record = try create(std.testing.allocator, account, "app.bsky.feed.post", "3ztest", parsed.value);
442
442
+
try std.testing.expectEqualStrings("3ztest", record.rkey);
443
443
+
444
444
+
const fetched = get(account.did, "app.bsky.feed.post", "3ztest").?;
445
445
+
try std.testing.expectEqualStrings(record.cid, fetched.cid);
446
446
+
try std.testing.expect(std.mem.indexOf(u8, fetched.value_json, "hello") != null);
201
447
}