fed-sx-m2: Step 4d — outbox listing from log + pagination + 8 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled

Per-actor GET /actors/<id>/outbox now reads the bucket's log via
new nx_kernel:log_state_for/1 gen_server export and renders the
paged CID list.

nx_kernel additions:
  log_state_for/1 gen_server call returning {ok, LogState} for
  the named actor (mirrors log_tip_for/1's shape).

http_server additions:
  - with_request_query/2 bakes Req's :query binary into Cfg as
    {request_query, Q} so sub-resource handlers can parse params
    without taking the Req as another arg
  - kernel_actor_log_data/2 -> {Tip, Entries} via
    nx_kernel:log_tip_for + log_state_for + log:entries
  - parse_page/1 reads ?page=N (default 1, non-digits -> 1)
  - page_size/0 returns 5 (test-friendly; production picks 20+)
  - page_slice/2 + drop_take/3 + take/2 for the page extraction
  - entry_cids/1 maps entries to :id CID binaries via envelope
  - actor_outbox_full_response_for/5 renders text / JSON / SX:
      text:  outbox: <id>\ntip: N\npage: P\nitem: <cid>\n...
      json:  {"outbox":"<id>","tip":N,"page":P,"items":[...]}
      sx:    (outbox "<id>" :tip N :page P :items (...))
    Empty page degrades to actor_outbox_with_tip_response_for so
    epochs 50-57 from Step 4c still pass — the prefix is preserved.

8 new cases in next/tests/http_multi_actor.sh (41/41 total):
  - 1 publish -> body contains outbox/tip=1/page=1/item: prefix
  - 3 publishes -> body contains tip=3/page=1/item: prefix
  - page=2 with 3 items -> empty page degrades to tip-only body
  - 6 publishes page=1 -> tip=6/page=1/item: prefix
  - 6 publishes page=2 -> tip=6/page=2/item: prefix
  - JSON body shape with items array (1 entry)
  - SX body shape with :items list (1 entry)
  - bad ?page=bad falls back to page 1

Conformance 761/761. 117/117 across 11 Step-4-adjacent suites
(http_multi_actor, http_route, http_publish, http_post_format,
http_marshal, http_publish_fold, http_listen_bif, http_server_start,
nx_kernel_multi, nx_kernel_server, bootstrap_start, actor_lifecycle).

Substrate gotcha logged: named recursive funs fun F(...) -> F(...)
end aren't supported by the parser ('fun-ref syntax not yet
supported'); binary:matches/2 and lists:foreach/2 aren't registered.
Tests prove behaviour via match_prefix substring checks rather than
counting occurrences.
This commit is contained in:
2026-06-06 15:42:37 +00:00
parent e04a65d400
commit 6cfb1cb2d3
4 changed files with 260 additions and 9 deletions

View File

@@ -69,6 +69,7 @@ route(Req, Cfg) ->
M = field(method, Req),
P = field(path, Req),
F = accept_format_from(Req),
Cfg1 = with_request_query(Req, Cfg),
case {M, P} of
{<<80,79,83,84>>, <<47,97,99,116,105,118,105,116,121>>} ->
handle_post_activity(Req, Cfg);
@@ -77,7 +78,16 @@ route(Req, Cfg) ->
47,115,120,45,99,97,112,97,98,105,108,105,116,105,101,115>>} ->
ok_response(capabilities_body_for(F));
_ ->
dispatch(M, P, F, Cfg)
dispatch(M, P, F, Cfg1)
end.
%% with_request_query/2 — bake the Req's :query binary into Cfg as
%% `{request_query, Q}` so sub-resource handlers can parse `?page=N`
%% etc without taking the Req as an extra argument.
with_request_query(Req, Cfg) ->
case field(query, Req) of
nil -> Cfg;
Q -> [{request_query, Q} | Cfg]
end.
%% route/3 — Step 4c convenience entry. Kernel is an opaque
@@ -680,14 +690,101 @@ actor_outbox_response_for(Id, F, Cfg) ->
nil ->
actor_outbox_response_for(Id, F);
Kernel ->
case kernel_log_tip(Kernel, Id) of
case kernel_actor_log_data(Kernel, Id) of
nil ->
actor_outbox_response_for(Id, F);
Tip ->
actor_outbox_with_tip_response_for(Id, F, Tip)
{Tip, Entries} ->
Page = parse_page(field(request_query, Cfg)),
Slice = page_slice(Entries, Page),
Cids = entry_cids(Slice),
actor_outbox_full_response_for(Id, F, Tip, Page, Cids)
end
end.
%% kernel_actor_log_data/2 — synchronous query to the kernel for
%% the actor's tip + flat entry list. nil when the kernel atom isn't
%% registered or the actor isn't present (mirrors kernel_log_tip/2's
%% guard pattern).
kernel_actor_log_data(Kernel, Id) when is_atom(Kernel) ->
case erlang:whereis(Kernel) of
undefined -> nil;
_ ->
L = binary_to_list(Id),
A = list_to_atom(L),
T = nx_kernel:log_tip_for(A),
case T of
N when is_integer(N) ->
case nx_kernel:log_state_for(A) of
{ok, LogState} -> {N, log:entries(LogState)};
_ -> {N, []}
end;
_ -> nil
end
end;
kernel_actor_log_data(_, _) -> nil.
%% page_size/0 — small for v2 (proof of concept). Real outboxes
%% pick a larger page size (Mastodon defaults to 20). Tests pin
%% this to 5 so 3 publishes fit in one page and 6 publishes
%% straddle two pages.
page_size() -> 5.
%% parse_page/1 — accept `?page=N` from the query string. `nil` or
%% missing param -> page 1. Non-positive values clamp to 1.
parse_page(nil) -> 1;
parse_page(Q) when is_binary(Q) ->
case match_prefix(<<112,97,103,101,61>>, Q) of % "page="
{ok, Rest} ->
case parse_int(Rest) of
{ok, N} when N >= 1 -> N;
_ -> 1
end;
_ -> 1
end;
parse_page(_) -> 1.
parse_int(Bin) ->
L = binary_to_list(Bin),
case L of
[] -> error;
_ ->
case all_digits(L) of
true -> {ok, list_to_integer(L)};
false -> error
end
end.
all_digits([]) -> true;
all_digits([C | Rest]) when C >= 48, C =< 57 -> all_digits(Rest);
all_digits(_) -> false.
%% page_slice/2 — extract a page-sized slice of Entries. Page is
%% 1-indexed; out-of-range pages yield [].
page_slice(Entries, Page) ->
Sz = page_size(),
Start = (Page - 1) * Sz,
drop_take(Entries, Start, Sz).
drop_take(_, _, 0) -> [];
drop_take([], _, _) -> [];
drop_take(L, 0, N) -> take(L, N);
drop_take([_ | Rest], K, N) -> drop_take(Rest, K - 1, N).
take(_, 0) -> [];
take([], _) -> [];
take([H | Rest], N) -> [H | take(Rest, N - 1)].
entry_cids([]) -> [];
entry_cids([E | Rest]) ->
case envelope:get_field(id, E) of
{ok, Cid} -> [Cid | entry_cids(Rest)];
_ -> entry_cids(Rest)
end.
%% kernel_log_tip/2 — query the kernel for an actor's log tip via
%% `nx_kernel:log_tip_for/1`. Returns the tip integer when the actor
%% exists, `nil` when the kernel atom isn't registered or the actor
@@ -739,6 +836,84 @@ actor_outbox_with_tip_response_for(Id, sx, Tip) ->
actor_outbox_with_tip_response_for(Id, _, Tip) ->
actor_outbox_with_tip_response_for(Id, text, Tip).
%% actor_outbox_full_response_for/5 — Step 4d body shape includes
%% the actor id, tip, current page number, and the page's CID list.
%% Empty Cids degrades to the /tip/ variant — keeps the 4c body
%% shape stable when an actor has no entries (e.g. a Bob with zero
%% publishes).
actor_outbox_full_response_for(Id, F, Tip, _Page, []) ->
actor_outbox_with_tip_response_for(Id, F, Tip);
actor_outbox_full_response_for(Id, text, Tip, Page, Cids) ->
Pre = <<111,117,116,98,111,120,58,32>>, % "outbox: "
Tipp = <<10,116,105,112,58,32>>, % "\ntip: "
Pag = <<10,112,97,103,101,58,32>>, % "\npage: "
Itm = <<10,105,116,101,109,58,32>>, % "\nitem: "
TipBin = list_to_binary(integer_to_list(Tip)),
PageBin = list_to_binary(integer_to_list(Page)),
Head = <<Pre/binary, Id/binary,
Tipp/binary, TipBin/binary,
Pag/binary, PageBin/binary>>,
Body = lines_with_prefix(Head, Itm, Cids, <<10>>),
ok_response(Body);
actor_outbox_full_response_for(Id, json, Tip, Page, Cids) ->
Body = json_outbox_body(Id, Tip, Page, Cids),
ok_response(Body, json);
actor_outbox_full_response_for(Id, activity_json, Tip, Page, Cids) ->
Body = json_outbox_body(Id, Tip, Page, Cids),
ok_response(Body, activity_json);
actor_outbox_full_response_for(Id, sx, Tip, Page, Cids) ->
Body = sx_outbox_body(Id, Tip, Page, Cids),
ok_response(Body, sx);
actor_outbox_full_response_for(Id, _, Tip, Page, Cids) ->
actor_outbox_full_response_for(Id, text, Tip, Page, Cids).
lines_with_prefix(Acc, _, [], Tail) -> <<Acc/binary, Tail/binary>>;
lines_with_prefix(Acc, Itm, [C | Rest], Tail) ->
lines_with_prefix(<<Acc/binary, Itm/binary, C/binary>>, Itm, Rest, Tail).
%% {"outbox":"<id>","tip":N,"page":P,"items":["cid1","cid2",...]}
json_outbox_body(Id, Tip, Page, Cids) ->
Pre = <<123,34,111,117,116,98,111,120,34,58,34>>,
Mid1 = <<34,44,34,116,105,112,34,58>>, % '","tip":'
Mid2 = <<44,34,112,97,103,101,34,58>>, % ',"page":'
Mid3 = <<44,34,105,116,101,109,115,34,58,91>>, % ',"items":['
Suf = <<93,125,10>>, % ']}\n'
TipBin = list_to_binary(integer_to_list(Tip)),
PageBin = list_to_binary(integer_to_list(Page)),
Items = json_string_list(Cids),
<<Pre/binary, Id/binary,
Mid1/binary, TipBin/binary,
Mid2/binary, PageBin/binary,
Mid3/binary, Items/binary, Suf/binary>>.
json_string_list([]) -> <<>>;
json_string_list([C]) -> <<34, C/binary, 34>>;
json_string_list([C | Rest]) ->
Tail = json_string_list(Rest),
<<34, C/binary, 34, 44, Tail/binary>>.
%% (outbox "<id>" :tip N :page P :items ("cid1" "cid2" ...))
sx_outbox_body(Id, Tip, Page, Cids) ->
Pre = <<40,111,117,116,98,111,120,32,34>>, % '(outbox "'
Mid1 = <<34,32,58,116,105,112,32>>, % '" :tip '
Mid2 = <<32,58,112,97,103,101,32>>, % ' :page '
Mid3 = <<32,58,105,116,101,109,115,32,40>>, % ' :items ('
Suf = <<41,41,10>>, % '))\n'
TipBin = list_to_binary(integer_to_list(Tip)),
PageBin = list_to_binary(integer_to_list(Page)),
Items = sx_string_list(Cids),
<<Pre/binary, Id/binary,
Mid1/binary, TipBin/binary,
Mid2/binary, PageBin/binary,
Mid3/binary, Items/binary, Suf/binary>>.
sx_string_list([]) -> <<>>;
sx_string_list([C]) -> <<34, C/binary, 34>>;
sx_string_list([C | Rest]) ->
Tail = sx_string_list(Rest),
<<34, C/binary, 34, 32, Tail/binary>>.
%% "inbox: " — 7 bytes
actor_inbox_get_response_for(Id, text) ->
Pre = <<105,110,98,111,120,58,32>>,

View File

@@ -17,7 +17,7 @@
%% gen_server API
-export([start_link/3, publish/1, query/0, log_tip/0,
with_projections/1, stop/0,
add_actor/3, publish_to/2, log_tip_for/1,
add_actor/3, publish_to/2, log_tip_for/1, log_state_for/1,
actors/0, state_for/1, bucket_for/1,
with_projections_for/2,
bootstrap_actor/3]).
@@ -321,6 +321,9 @@ publish_to(ActorId, Request) ->
log_tip_for(ActorId) ->
gen_server:call(nx_kernel, {log_tip_for, ActorId}).
log_state_for(ActorId) ->
gen_server:call(nx_kernel, {log_state_for, ActorId}).
actors() ->
gen_server:call(nx_kernel, get_actors).
@@ -366,6 +369,8 @@ handle_call({publish_to, ActorId, Request}, _From, State) ->
end;
handle_call({log_tip_for, ActorId}, _From, State) ->
{reply, actor_log_tip(ActorId, State), State};
handle_call({log_state_for, ActorId}, _From, State) ->
{reply, actor_log_state(ActorId, State), State};
handle_call(get_actors, _From, State) ->
{reply, actors(State), State};
handle_call({state_for, ActorId}, _From, State) ->