feat(leaderboard): bring your own backend
This commit is contained in:
parent
1ca1d63ea7
commit
cd2b30ecc8
4 changed files with 189 additions and 4 deletions
|
|
@ -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 } })
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
102
packages/leaderboard/test/firebase-adapter.test.js
Normal file
102
packages/leaderboard/test/firebase-adapter.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
48
packages/leaderboard/test/supabase-adapter.test.js
Normal file
48
packages/leaderboard/test/supabase-adapter.test.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue