@profullstack/leaderboard 0.2.0 → 0.3.0
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 +23 -2
- package/index.d.ts +10 -0
- package/package.json +5 -1
- package/src/index.js +1 -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.
|
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.0",
|
|
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 };
|
|
@@ -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
|
+
}
|