Skip to content

Commit 0f15986

Browse files
authored
[examples/hackernews] Various improvements. (#776)
<img width="898" alt="Screenshot 2025-02-26 at 17 04 50" src="https://github.com/user-attachments/assets/556b54c6-0493-4867-8744-f74de2ebbb74" />
2 parents 7aabc48 + 6e7f0ad commit 0f15986

File tree

11 files changed

+731
-191
lines changed

11 files changed

+731
-191
lines changed

examples/hackernews/db/schema.sql

+28-6
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ CREATE TABLE posts (
88
"author_id" INTEGER,
99
"title" TEXT,
1010
"url" TEXT,
11-
"body" TEXT
11+
"body" TEXT,
12+
"date" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
1213
);
1314
CREATE TABLE upvotes (
1415
"id" SERIAL PRIMARY KEY,
@@ -18,8 +19,29 @@ CREATE TABLE upvotes (
1819

1920
-- Fixtures
2021
INSERT INTO users("id", "name", "email") VALUES
21-
(1, 'Lucas', '[email protected]'),
22-
(2, 'Daniel', '[email protected]');
23-
INSERT INTO posts("title", "url", "body", "author_id") VALUES
24-
('Hello!', 'http://foo.org', 'This is a post', 1),
25-
('Reactive stuff', 'http://bar.net', 'This is neat.', 2);
22+
(1, 'Benno', '[email protected]'),
23+
(2, 'Charles', '[email protected]'),
24+
(3, 'Daniel', '[email protected]'),
25+
(4, 'Josh', '[email protected]'),
26+
(5, 'Julien', '[email protected]'),
27+
(6, 'Laure', '[email protected]'),
28+
(7, 'Lucas', '[email protected]'),
29+
(8, 'Mehdi', '[email protected]');
30+
SELECT setval('users_id_seq', max(id) + 1) FROM users;
31+
32+
INSERT INTO posts("id", "title", "url", "body", "author_id", "date") VALUES
33+
(0, 'Skip''s Origins', 'https://skiplabs.io/blog/skips-origins', '', 5, '2025-02-11 00:00:00+00'),
34+
(1, 'New skiplabs website!', 'https://skiplabs.io', '', 2, '2025-01-12 00:00:00+00'),
35+
(2, 'Why Skip?', 'https://skiplabs.io/blog/why-skip', '', 5, '2025-02-11 00:00:00+00'),
36+
(3, 'Skip alpha just dropped', 'https://skiplabs.io/blog/skip-alpha', '', 3, '2024-12-24 00:00:00+00'),
37+
(4, 'Skip docs website', 'https://skiplabs.io/docs/introduction', '', 4, '2024-11-11 00:00:00+00'),
38+
(5, 'Building a HN clone with Skip', 'https://github.com/SkipLabs/skip/pull/343/', '', 7, '2024-09-28 00:00:00+00');
39+
SELECT setval('posts_id_seq', max(id) + 1) FROM posts;
40+
41+
INSERT INTO upvotes("user_id", "post_id") VALUES
42+
(1, 0), (2, 0), (5, 0), (8, 0),
43+
(1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 1), (8, 1),
44+
(3, 2), (5, 2), (7, 2),
45+
(1, 3), (3, 3),
46+
(2, 4), (3, 4), (5, 4),
47+
(1, 5), (2, 5), (3, 5), (4, 5), (5, 5), (6, 5), (8, 5);

examples/hackernews/reactive_service/src/hackernews.service.ts

+119-26
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type Post = {
1414
title: string;
1515
url: string;
1616
body: string;
17+
date: number;
1718
};
1819

1920
type User = {
@@ -26,10 +27,16 @@ type Upvote = {
2627
user_id: number;
2728
};
2829

29-
type Upvoted = Post & { upvotes: number; author: User };
30+
type PostWithUpvoteIds = Post & { upvotes: number[]; author: User };
3031

31-
type ResourceInputs = {
32-
postsWithUpvotes: EagerCollection<number, Upvoted>;
32+
type PostWithUpvoteCount = Omit<Post, "author_id"> & {
33+
upvotes: number;
34+
upvoted: boolean;
35+
author: User;
36+
};
37+
38+
type Session = User & {
39+
user_id: number;
3340
};
3441

3542
const postgres = new PostgresExternalService({
@@ -41,9 +48,9 @@ const postgres = new PostgresExternalService({
4148
});
4249

4350
class UpvotesMapper {
44-
mapEntry(key: number, values: Values<Upvote>): Iterable<[number, number]> {
45-
const value = values.getUnique().post_id;
46-
return [[value, key]];
51+
mapEntry(_key: number, values: Values<Upvote>): Iterable<[number, number]> {
52+
const upvote: Upvote = values.getUnique();
53+
return [[upvote.post_id, upvote.user_id]];
4754
}
4855
}
4956

@@ -53,47 +60,132 @@ class PostsMapper {
5360
private upvotes: EagerCollection<number, number>,
5461
) {}
5562

56-
mapEntry(key: number, values: Values<Post>): Iterable<[number, Upvoted]> {
63+
mapEntry(
64+
key: number,
65+
values: Values<Post>,
66+
): Iterable<[[number, number], PostWithUpvoteIds]> {
5767
const post: Post = values.getUnique();
58-
const upvotes = this.upvotes.getArray(key).length;
68+
const upvotes: number[] = this.upvotes.getArray(key);
5969
let author;
6070
try {
6171
author = this.users.getUnique(post.author_id);
6272
} catch {
6373
author = { name: "unknown author", email: "unknown email" };
6474
}
65-
// Projecting all posts on key 0 so that they can later be sorted.
66-
return [[0, { ...post, upvotes, author }]];
75+
return [[[-upvotes.length, key], { ...post, upvotes, author }]];
6776
}
6877
}
6978

70-
class SortingMapper {
71-
mapEntry(key: number, values: Values<Upvoted>): Iterable<[number, Upvoted]> {
72-
const posts = values.toArray();
73-
// Sorting in descending order of upvotes.
74-
posts.sort((a, b) => b.upvotes - a.upvotes);
75-
return posts.map((p) => [key, p]);
79+
class CleanupMapper {
80+
constructor(private readonly session: Session | null) {}
81+
82+
mapEntry(
83+
key: [number, number],
84+
values: Values<PostWithUpvoteIds>,
85+
): Iterable<[number, PostWithUpvoteCount]> {
86+
const post = values.getUnique();
87+
let upvoted;
88+
if (this.session === null) upvoted = false;
89+
else upvoted = post.upvotes.includes(this.session.user_id);
90+
const upvotes = post.upvotes.length;
91+
return [
92+
[
93+
key[1],
94+
{
95+
title: post.title,
96+
url: post.url,
97+
body: post.body,
98+
date: post.date,
99+
author: post.author,
100+
upvotes,
101+
upvoted,
102+
},
103+
],
104+
];
76105
}
77106
}
78107

79-
class PostsResource implements Resource<ResourceInputs> {
108+
type PostsResourceInputs = {
109+
postsWithUpvotes: EagerCollection<[number, number], PostWithUpvoteIds>;
110+
sessions: EagerCollection<string, Session>;
111+
};
112+
113+
type PostsResourceParams = { limit?: number; session_id?: string };
114+
115+
class PostsResource implements Resource<PostsResourceInputs> {
80116
private limit: number;
117+
private session_id: string;
81118

82-
constructor(param: Json) {
83-
if (typeof param == "number") this.limit = param;
84-
else this.limit = 25;
119+
constructor(jsonParams: Json) {
120+
const params = jsonParams as PostsResourceParams;
121+
if (params.limit === undefined) this.limit = 25;
122+
else this.limit = params.limit;
123+
if (params.session_id === undefined)
124+
throw new Error("Missing required session_id.");
125+
else this.session_id = params.session_id as string;
85126
}
86127

87-
instantiate(collections: ResourceInputs): EagerCollection<number, Upvoted> {
88-
return collections.postsWithUpvotes.take(this.limit).map(SortingMapper);
128+
instantiate(
129+
collections: PostsResourceInputs,
130+
): EagerCollection<number, PostWithUpvoteCount> {
131+
let session;
132+
try {
133+
session = collections.sessions.getUnique(this.session_id);
134+
} catch {
135+
session = null;
136+
}
137+
return collections.postsWithUpvotes
138+
.take(this.limit)
139+
.map(CleanupMapper, session);
89140
}
90141
}
91142

92-
export const service: SkipService<{}, ResourceInputs> = {
93-
initialData: {},
94-
resources: { posts: PostsResource },
143+
class FilterSessionMapper {
144+
constructor(private session_id: string) {}
145+
146+
mapEntry(key: string, values: Values<Session>): Iterable<[number, Session]> {
147+
if (key != this.session_id) return [];
148+
const sessions = values.toArray();
149+
if (sessions.length > 0) return [[0, sessions[0] as Session]];
150+
else return [];
151+
}
152+
}
153+
154+
type SessionsResourceInputs = {
155+
sessions: EagerCollection<string, Session>;
156+
};
157+
158+
class SessionsResource implements Resource<SessionsResourceInputs> {
159+
private session_id: string;
160+
161+
constructor(jsonParams: Json) {
162+
const params = jsonParams as PostsResourceParams;
163+
if (params.session_id === undefined)
164+
throw new Error("Missing required session_id.");
165+
else this.session_id = params.session_id as string;
166+
}
167+
168+
instantiate(
169+
collections: SessionsResourceInputs,
170+
): EagerCollection<number, Session> {
171+
return collections.sessions.map(FilterSessionMapper, this.session_id);
172+
}
173+
}
174+
175+
type PostsServiceInputs = {
176+
sessions: EagerCollection<string, Session>;
177+
};
178+
179+
export const service: SkipService<PostsServiceInputs, PostsResourceInputs> = {
180+
initialData: {
181+
sessions: [],
182+
},
183+
resources: { posts: PostsResource, sessions: SessionsResource },
95184
externalServices: { postgres },
96-
createGraph(_: {}, context: Context): ResourceInputs {
185+
createGraph(
186+
inputs: PostsServiceInputs,
187+
context: Context,
188+
): PostsResourceInputs {
97189
const serialIDKey = { key: { col: "id", type: "SERIAL" } };
98190
const posts = context.useExternalResource<number, Post>({
99191
service: "postgres",
@@ -116,6 +208,7 @@ export const service: SkipService<{}, ResourceInputs> = {
116208
users,
117209
upvotes.map(UpvotesMapper),
118210
),
211+
sessions: inputs.sessions,
119212
};
120213
},
121214
};

0 commit comments

Comments
 (0)