@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 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(); // one process, tests
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.2.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
+ }