@profullstack/leaderboard 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -2
- package/index.d.ts +10 -0
- package/package.json +5 -1
- package/src/index.js +1 -0
- package/src/next.js +7 -0
- package/src/store/projection.js +58 -0
package/README.md
CHANGED
|
@@ -142,12 +142,33 @@ Every number is an option (`base`, `cap`, `perProperty`, `perNiche`, `maxPropert
|
|
|
142
142
|
## Stores
|
|
143
143
|
|
|
144
144
|
```js
|
|
145
|
-
import { memoryStore, sqlStore } from '@profullstack/leaderboard';
|
|
145
|
+
import { memoryStore, sqlStore, projectionStore } from '@profullstack/leaderboard';
|
|
146
146
|
|
|
147
|
-
memoryStore();
|
|
147
|
+
memoryStore(); // one process, tests
|
|
148
148
|
sqlStore({ execute, prefix: 'lb_', dialect: 'sqlite' }) // or 'postgres'
|
|
149
|
+
projectionStore({ events, gauges, badges }) // rank tables you already have
|
|
149
150
|
```
|
|
150
151
|
|
|
152
|
+
### Ranking data you already record
|
|
153
|
+
|
|
154
|
+
Most sites that want a leaderboard are already recording the facts it would rank. Copying those into a second set of tables is a dual write, and a dual write means the board and the ledger disagree the first time one of them fails. `projectionStore` reads the source instead:
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
const store = projectionStore({
|
|
158
|
+
events: async ({ since }) => {
|
|
159
|
+
const rows = await sql`select payer, total_cents, created_at from crawl_sales
|
|
160
|
+
where created_at >= ${new Date(since)}`;
|
|
161
|
+
return rows.map((r) => ({
|
|
162
|
+
player: r.payer, name: r.payer, metric: 'spent',
|
|
163
|
+
delta: r.total_cents, at: +new Date(r.created_at),
|
|
164
|
+
}));
|
|
165
|
+
},
|
|
166
|
+
badges: sqlStore({ execute }), // badges are awarded, not derived, so they need a home
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`since` is `0` for an all-time board, so the query has to handle "everything". The store is read-only: `record()` and `set()` throw rather than drop a write the next projection would overwrite.
|
|
171
|
+
|
|
151
172
|
`sqlStore` needs `execute({ sql, args }) -> { rows }`, which is `@libsql/client` as is. `store.schema` is the DDL for your own migration tool; `store.migrate()` runs it. Three tables: events, gauges, badges.
|
|
152
173
|
|
|
153
174
|
Anything else implements six methods: `append`, `list({ since })`, `setGauge`, `gauges`, `awardBadge`, `badges`. The core reads all events and aggregates in memory, cached for `cacheMs` (15 s); that is fine into the hundreds of thousands of events. Call `lb.invalidate()` after writing to the store from elsewhere.
|
|
@@ -161,6 +182,16 @@ export const { GET } = leaderboardRoute(lb);
|
|
|
161
182
|
export const dynamic = 'force-dynamic';
|
|
162
183
|
```
|
|
163
184
|
|
|
185
|
+
That serves everything **under** the base path. It does not serve `/leaderboard.json`, because Next matches on path segments and that path is a sibling of `/leaderboard` rather than a child, so the catch-all never sees it. Either use `/leaderboard/index.json`, which is the same index, or give it a route of its own:
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
// app/leaderboard.json/route.js
|
|
189
|
+
export const { GET } = leaderboardRoute(lb);
|
|
190
|
+
export const dynamic = 'force-dynamic';
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Testing this needs the router, not just the handler: calling the exported `GET` directly answers every path you hand it, including the ones Next would never route to it.
|
|
194
|
+
|
|
164
195
|
## Programmatic
|
|
165
196
|
|
|
166
197
|
```js
|
package/index.d.ts
CHANGED
|
@@ -26,6 +26,16 @@ export interface SqlStoreOptions {
|
|
|
26
26
|
export interface SqlStore extends Store { schema: string[]; tables: { events: string; gauges: string; badges: string }; migrate(): Promise<void> }
|
|
27
27
|
export function sqlStore(options: SqlStoreOptions): SqlStore;
|
|
28
28
|
|
|
29
|
+
export interface ProjectionOptions {
|
|
30
|
+
/** Build events from rows you already have. `since` is 0 for all time. */
|
|
31
|
+
events(opts: { since: number }): Promise<Event[]> | Event[];
|
|
32
|
+
gauges?(): Promise<Record<string, { name?: string; at: number; values: Record<string, number> }>> | Record<string, { name?: string; at: number; values: Record<string, number> }>;
|
|
33
|
+
/** Where awarded badges persist. Defaults to memory, which does not survive a restart. */
|
|
34
|
+
badges?: Pick<Store, 'awardBadge' | 'badges'>;
|
|
35
|
+
}
|
|
36
|
+
/** A read-only store over existing tables. `record()` and `set()` throw. */
|
|
37
|
+
export function projectionStore(options: ProjectionOptions): Store;
|
|
38
|
+
|
|
29
39
|
export interface LadderOptions {
|
|
30
40
|
base?: number; cap?: number; perProperty?: number; perNiche?: number; maxProperties?: number; maxNiches?: number;
|
|
31
41
|
propertiesMetric?: string; nichesMetric?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/leaderboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A public leaderboard with badges, streaks and a commission ladder that drops into any site. Ranks either side of a transaction, earners and spenders and usage, kept segregated. Zero dependencies, Fetch API core, Hono and Next adapters.",
|
|
6
6
|
"keywords": [
|
|
@@ -60,6 +60,10 @@
|
|
|
60
60
|
"./store/sql": {
|
|
61
61
|
"types": "./index.d.ts",
|
|
62
62
|
"import": "./src/store/sql.js"
|
|
63
|
+
},
|
|
64
|
+
"./store/projection": {
|
|
65
|
+
"types": "./index.d.ts",
|
|
66
|
+
"import": "./src/store/projection.js"
|
|
63
67
|
}
|
|
64
68
|
},
|
|
65
69
|
"types": "./index.d.ts",
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { commissionLadder } from './ladder.js';
|
|
|
7
7
|
export { defaultBadges } from './badges.js';
|
|
8
8
|
export { memoryStore } from './store/memory.js';
|
|
9
9
|
export { sqlStore } from './store/sql.js';
|
|
10
|
+
export { projectionStore } from './store/projection.js';
|
|
10
11
|
|
|
11
12
|
const DAY = 86_400_000;
|
|
12
13
|
const PERIODS = { all: 0, day: DAY, week: 7 * DAY, month: 30 * DAY };
|
package/src/next.js
CHANGED
|
@@ -10,6 +10,13 @@ import { createLeaderboard } from './index.js';
|
|
|
10
10
|
*
|
|
11
11
|
* Anything the leaderboard does not answer 404s, which is what a route under
|
|
12
12
|
* its own path should do.
|
|
13
|
+
*
|
|
14
|
+
* This serves everything UNDER the base path, and not `<base>.json`: Next
|
|
15
|
+
* matches on path segments, so that path is a sibling of the base rather than
|
|
16
|
+
* a child and the catch-all never sees it. Use `<base>/index.json`, which is
|
|
17
|
+
* the same index, or give `app/<base>.json/route.js` its own copy of this.
|
|
18
|
+
* Note that calling the exported GET directly in a test answers any path it is
|
|
19
|
+
* handed, including ones Next would never route to it.
|
|
13
20
|
*/
|
|
14
21
|
export function leaderboardRoute(lbOrOptions) {
|
|
15
22
|
const lb = lbOrOptions && typeof lbOrOptions.handle === 'function' ? lbOrOptions : createLeaderboard(lbOrOptions);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { memoryStore } from './memory.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A read-through store over tables you already have.
|
|
5
|
+
*
|
|
6
|
+
* Most sites that could show a leaderboard are already recording the facts it
|
|
7
|
+
* would rank: sales, payments, referral commissions, requests served. Copying
|
|
8
|
+
* those into a second set of tables means a dual write, and a dual write means
|
|
9
|
+
* the board and the ledger disagree the first time one of them fails. So
|
|
10
|
+
* project instead: hand back events built from the rows you already trust.
|
|
11
|
+
*
|
|
12
|
+
* projectionStore({
|
|
13
|
+
* events: async ({ since }) => {
|
|
14
|
+
* const rows = await sql`select payer, total_cents, created_at from crawl_sales
|
|
15
|
+
* where created_at >= ${new Date(since)}`;
|
|
16
|
+
* return rows.map((r) => ({
|
|
17
|
+
* player: r.payer, name: r.payer, metric: 'spent',
|
|
18
|
+
* delta: r.total_cents, at: +new Date(r.created_at),
|
|
19
|
+
* }));
|
|
20
|
+
* },
|
|
21
|
+
* })
|
|
22
|
+
*
|
|
23
|
+
* `since` is 0 for an all-time board, so the query must handle "everything".
|
|
24
|
+
* Badges still need somewhere durable to live, since they are awarded rather
|
|
25
|
+
* than derived: pass `badges` (a `sqlStore`, or any object with `awardBadge`
|
|
26
|
+
* and `badges`). The default keeps them in memory, which is fine for a board
|
|
27
|
+
* with no badges and wrong for one with them.
|
|
28
|
+
*
|
|
29
|
+
* The store is read-only. `record()` and `set()` on a leaderboard backed by a
|
|
30
|
+
* projection throw, rather than silently dropping a write that the projection
|
|
31
|
+
* would overwrite on the next read anyway.
|
|
32
|
+
*/
|
|
33
|
+
export function projectionStore({ events, gauges, badges } = {}) {
|
|
34
|
+
if (typeof events !== 'function') throw new TypeError('projectionStore needs an events({ since }) function');
|
|
35
|
+
const badgeStore = badges ?? memoryStore();
|
|
36
|
+
const readOnly = (method) => () => {
|
|
37
|
+
throw new Error(`${method}() is not available on a projection store: write to the source table instead, the board reads it back`);
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
async list({ since = 0 } = {}) {
|
|
41
|
+
const rows = (await events({ since })) ?? [];
|
|
42
|
+
return rows.map((e) => ({
|
|
43
|
+
player: String(e.player),
|
|
44
|
+
name: e.name == null ? undefined : String(e.name),
|
|
45
|
+
metric: String(e.metric),
|
|
46
|
+
delta: Number(e.delta) || 0,
|
|
47
|
+
at: Number(e.at) || 0,
|
|
48
|
+
}));
|
|
49
|
+
},
|
|
50
|
+
async gauges() {
|
|
51
|
+
return (typeof gauges === 'function' ? await gauges() : null) ?? {};
|
|
52
|
+
},
|
|
53
|
+
append: readOnly('record'),
|
|
54
|
+
setGauge: readOnly('set'),
|
|
55
|
+
awardBadge: (...a) => badgeStore.awardBadge(...a),
|
|
56
|
+
badges: () => badgeStore.badges(),
|
|
57
|
+
};
|
|
58
|
+
}
|