feat(leaderboard): bring your own backend

This commit is contained in:
ayo 2026-07-04 13:45:57 +02:00
parent 1ca1d63ea7
commit cd2b30ecc8
4 changed files with 189 additions and 4 deletions

View file

@ -149,11 +149,30 @@ Want to author your own custom elements this way? Check out
## Choosing a backend ## Choosing a backend
### Bring your own backend instance (injection)
Both adapters let the **consumer own the backend instance** — including a
privileged/admin-level or server-side one — rather than the package creating its
own. This is the injection point per adapter:
| Adapter | Injection point | Internal init fallback |
| ---------- | -------------------------- | ----------------------------------- |
| Supabase | `client` (a supabase-js client you build) — **always** consumer-supplied; the package takes no supabase dependency | none — a client is required |
| Firebase | `store` (a Firestore instance you build) | `firebaseConfig` → the package initializes its own app |
Supply a privileged instance and every read/write runs against it — the package
adds no auth or app lifecycle of its own.
### Firebase (Firestore) ### Firebase (Firestore)
```js ```js
import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js' import { FirebaseAdapter } from '@cozy-games/leaderboard/adapters/firebase.js'
// (a) let the package initialize from a public config:
const adapter = new FirebaseAdapter({ firebaseConfig, namespace: 'mw' }) const adapter = new FirebaseAdapter({ firebaseConfig, namespace: 'mw' })
// (b) OR inject a Firestore instance you built (e.g. privileged/server-side):
const adapter = new FirebaseAdapter({ store: myFirestore, namespace: 'mw' })
``` ```
Needs the `firebase` peer dependency. Uses collections Needs the `firebase` peer dependency. Uses collections
@ -162,9 +181,10 @@ Needs the `firebase` peer dependency. Uses collections
and all-time sorts by `score`, so only Firestore's automatic single-field indexes and all-time sorts by `score`, so only Firestore's automatic single-field indexes
are needed — no composite indexes to deploy. are needed — no composite indexes to deploy.
For local development, pass `emulator` to run against the With an injected `store` the package initializes nothing and owns no app
lifecycle. For internal init, pass `emulator` to run against the
[Firestore emulator](https://firebase.google.com/docs/emulator-suite) — no cloud, [Firestore emulator](https://firebase.google.com/docs/emulator-suite) — no cloud,
no deploy: no deploy (wire the emulator into your own store if you inject one):
```js ```js
new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } }) new FirebaseAdapter({ firebaseConfig, namespace: 'mw', emulator: { host: '127.0.0.1', port: 8080 } })

View file

@ -12,13 +12,28 @@ import {
export class FirebaseAdapter { export class FirebaseAdapter {
/** /**
* Supply EITHER a ready Firestore instance via `store` (the injection point
* e.g. a privileged/server-side setup, or an app you already initialized), OR a
* `firebaseConfig` for the package to initialize its own app. `store` wins when
* both are given; with an injected store the package initializes nothing and
* owns no app lifecycle (so `emulator` a convenience of internal init is
* ignored; wire the emulator into your own store).
*
* @param {Object} options * @param {Object} options
* @param {Object} options.firebaseConfig - Firebase app config (public; access governed by security rules) * @param {Object} [options.store] - a Firestore instance to use as-is (injection point)
* @param {Object} [options.firebaseConfig] - Firebase app config for internal init (public; access governed by security rules)
* @param {String} [options.namespace] - collection prefix * @param {String} [options.namespace] - collection prefix
* @param {{ host?: string, port?: number }} [options.emulator] - point at a local Firestore emulator (dev/test only) * @param {{ host?: string, port?: number }} [options.emulator] - point the internally-created store at a local Firestore emulator (dev/test only)
*/ */
constructor(options = {}) { constructor(options = {}) {
this.namespace = options.namespace || 'lb' this.namespace = options.namespace || 'lb'
if (options.store) {
this.store = options.store
return
}
if (!options.firebaseConfig) {
throw new TypeError('FirebaseAdapter: provide either `store` (a Firestore instance) or `firebaseConfig`')
}
const app = initializeApp(options.firebaseConfig) const app = initializeApp(options.firebaseConfig)
this.store = getFirestore(app) this.store = getFirestore(app)
if (options.emulator) { if (options.emulator) {

View file

@ -0,0 +1,102 @@
// @ts-check
import { describe, it, expect, beforeEach, vi } from 'vitest'
// Stub the firebase SDK at the adapter boundary so no real app/network is touched.
const fb = vi.hoisted(() => ({
initializeApp: vi.fn(() => ({ __app: true })),
getFirestore: vi.fn(() => ({ __internalStore: true })),
connectFirestoreEmulator: vi.fn(),
doc: vi.fn((...args) => ({ __ref: args })),
getDoc: vi.fn(async () => ({ data: () => ({ passingStatus: 'ok' }) })),
getDocs: vi.fn(async () => ({ docs: [] })),
setDoc: vi.fn(async () => {}),
collection: vi.fn((...args) => ({ __col: args })),
query: vi.fn((...args) => ({ __q: args })),
where: vi.fn((...args) => ({ __where: args })),
orderBy: vi.fn((...args) => ({ __order: args })),
limit: vi.fn((...args) => ({ __limit: args }))
}))
vi.mock('firebase/app', () => ({ initializeApp: fb.initializeApp }))
vi.mock('firebase/firestore/lite', () => ({
getFirestore: fb.getFirestore,
connectFirestoreEmulator: fb.connectFirestoreEmulator,
doc: fb.doc,
getDoc: fb.getDoc,
getDocs: fb.getDocs,
setDoc: fb.setDoc,
collection: fb.collection,
query: fb.query,
where: fb.where,
orderBy: fb.orderBy,
limit: fb.limit
}))
import { FirebaseAdapter } from '../adapters/firebase.js'
beforeEach(() => vi.clearAllMocks())
describe('FirebaseAdapter — consumer-supplied (injected) store', () => {
it('uses an injected store as-is and initializes nothing', () => {
// Stand-in for a privileged / server-side Firestore instance.
const store = { __adminStore: true }
const adapter = new FirebaseAdapter({ store, namespace: 'mw' })
expect(adapter.store).toBe(store)
expect(fb.initializeApp).not.toHaveBeenCalled()
expect(fb.getFirestore).not.toHaveBeenCalled()
})
it('performs a write through the injected store', async () => {
const store = { __adminStore: true }
const adapter = new FirebaseAdapter({ store, namespace: 'mw' })
const entry = { name: 'A', score: 42, category: 'beginner', time_stamp: 123 }
await adapter.addScore('beginner', entry)
// The collection was built from OUR store, and the entry was written.
expect(fb.collection).toHaveBeenCalledWith(store, 'mw-scores', 'beginner', 'games')
expect(fb.setDoc).toHaveBeenCalledTimes(1)
expect(fb.setDoc.mock.calls[0][1]).toEqual(entry)
})
it('archives through the injected store', async () => {
const store = { __adminStore: true }
const adapter = new FirebaseAdapter({ store, namespace: 'mw' })
await adapter.archive({ playerId: 'p1', score: 9, category: 'beginner', time_stamp: 5 })
expect(fb.doc).toHaveBeenCalledWith(store, 'mw-all', 'p1', 'games', expect.any(String))
expect(fb.setDoc).toHaveBeenCalledTimes(1)
})
it('does not auto-connect the emulator for an injected store (consumer owns it)', () => {
new FirebaseAdapter({ store: { __s: 1 }, namespace: 'mw', emulator: { host: 'x', port: 1 } })
expect(fb.connectFirestoreEmulator).not.toHaveBeenCalled()
})
it('prefers the injected store when both store and firebaseConfig are given', () => {
const store = { __adminStore: true }
const adapter = new FirebaseAdapter({ store, firebaseConfig: { projectId: 'p' } })
expect(adapter.store).toBe(store)
expect(fb.initializeApp).not.toHaveBeenCalled()
})
})
describe('FirebaseAdapter — internal init (existing path, unbroken)', () => {
it('still initializes its own store from firebaseConfig and writes', async () => {
const adapter = new FirebaseAdapter({ firebaseConfig: { projectId: 'p' }, namespace: 'mw' })
expect(fb.initializeApp).toHaveBeenCalledWith({ projectId: 'p' })
expect(fb.getFirestore).toHaveBeenCalledTimes(1)
expect(adapter.store).toEqual({ __internalStore: true })
await adapter.addScore('beginner', { score: 1 })
expect(fb.setDoc).toHaveBeenCalledTimes(1)
})
it('still connects the emulator when configured', () => {
new FirebaseAdapter({ firebaseConfig: {}, emulator: { host: '127.0.0.1', port: 8080 } })
expect(fb.connectFirestoreEmulator).toHaveBeenCalledWith({ __internalStore: true }, '127.0.0.1', 8080)
})
it('throws a clear error when neither store nor firebaseConfig is given', () => {
expect(() => new FirebaseAdapter({})).toThrow(TypeError)
})
})

View file

@ -0,0 +1,48 @@
// @ts-check
import { describe, it, expect, vi } from 'vitest'
import { SupabaseAdapter } from '../adapters/supabase.js'
// A chainable stand-in for a supabase-js client (consumer-constructed — the
// package takes no supabase dependency). A privileged/service-role client is
// supplied the same way.
function mockClient() {
const insert = vi.fn(async () => ({ error: null }))
const from = vi.fn(() => ({ insert }))
return { from, insert }
}
describe('SupabaseAdapter — consumer-supplied client (injection point exists)', () => {
it('uses the injected client as-is', () => {
const client = mockClient()
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
expect(adapter.client).toBe(client)
})
it('performs a write through the injected client', async () => {
const client = mockClient()
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
await adapter.addScore('beginner', {
name: 'A', playerId: 'p1', score: 5, category: 'beginner', time_stamp: 't'
})
expect(client.from).toHaveBeenCalledWith('mw_scores')
expect(client.insert).toHaveBeenCalledTimes(1)
expect(client.insert.mock.calls[0][0]).toMatchObject({ name: 'A', player_id: 'p1', score: 5 })
})
it('archives through the injected client', async () => {
const client = mockClient()
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
await adapter.archive({ playerId: 'p1', score: 9, category: 'beginner', time_stamp: 't' })
expect(client.from).toHaveBeenCalledWith('mw_archive')
expect(client.insert.mock.calls[0][0]).toMatchObject({ player_id: 'p1', score: 9 })
})
it('surfaces backend errors from the injected client', async () => {
const insert = vi.fn(async () => ({ error: new Error('permission denied') }))
const client = { from: vi.fn(() => ({ insert })) }
const adapter = new SupabaseAdapter({ client, namespace: 'mw' })
await expect(adapter.addScore('beginner', { playerId: 'p' })).rejects.toThrow('permission denied')
})
})