feat(platform-service): migrate 6 repositories to @bytelyst/datastore (flags, settings, comments, votes, products, themes) — 756/756 tests pass
This commit is contained in:
parent
dfa5eb73fa
commit
4d126cb051
@ -10,7 +10,6 @@
|
||||
export interface BaseDocument {
|
||||
id: string;
|
||||
productId: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ── Filter operators ────────────────────────────────────────────────────────
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -558,6 +558,9 @@ importers:
|
||||
'@bytelyst/cosmos':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/cosmos
|
||||
'@bytelyst/datastore':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/datastore
|
||||
'@bytelyst/errors':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/errors
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
"@bytelyst/blob": "workspace:*",
|
||||
"@bytelyst/config": "workspace:*",
|
||||
"@bytelyst/cosmos": "workspace:*",
|
||||
"@bytelyst/datastore": "workspace:*",
|
||||
"@bytelyst/errors": "workspace:*",
|
||||
"@bytelyst/events": "workspace:*",
|
||||
"@bytelyst/fastify-core": "workspace:*",
|
||||
|
||||
67
services/platform-service/src/lib/datastore.ts
Normal file
67
services/platform-service/src/lib/datastore.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Cloud-agnostic datastore bridge for platform-service.
|
||||
*
|
||||
* Wraps @bytelyst/datastore with platform-service container registry config.
|
||||
* Repositories import getCollection() from here instead of getContainer() from cosmos.
|
||||
*
|
||||
* Migration: Replace `import { getContainer } from '../../lib/cosmos.js'`
|
||||
* with `import { getCollection } from '../../lib/datastore.js'`
|
||||
*/
|
||||
|
||||
import {
|
||||
type DatastoreProvider,
|
||||
type DocumentCollection,
|
||||
type BaseDocument,
|
||||
setDatastore,
|
||||
CosmosDatastoreProvider,
|
||||
MemoryDatastoreProvider,
|
||||
} from '@bytelyst/datastore';
|
||||
|
||||
let _provider: DatastoreProvider | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the datastore provider.
|
||||
* Call once at service startup (before any repository calls).
|
||||
*/
|
||||
export function initDatastore(): DatastoreProvider {
|
||||
if (_provider) return _provider;
|
||||
|
||||
const dbProvider = (process.env.DB_PROVIDER || 'cosmos').toLowerCase();
|
||||
|
||||
if (dbProvider === 'memory') {
|
||||
_provider = new MemoryDatastoreProvider();
|
||||
} else {
|
||||
_provider = new CosmosDatastoreProvider();
|
||||
}
|
||||
|
||||
setDatastore(_provider);
|
||||
return _provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a provider directly (for testing).
|
||||
*/
|
||||
export function setProvider(provider: DatastoreProvider): void {
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a typed collection from the datastore.
|
||||
* Drop-in replacement for getContainer() — returns a DocumentCollection instead of a Cosmos Container.
|
||||
*/
|
||||
export function getCollection<T extends BaseDocument = BaseDocument>(
|
||||
name: string,
|
||||
partitionKeyPath: string = '/productId'
|
||||
): DocumentCollection<T> {
|
||||
if (!_provider) {
|
||||
initDatastore();
|
||||
}
|
||||
return _provider!.getCollection<T>(name, partitionKeyPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal — for testing only
|
||||
*/
|
||||
export function _resetDatastoreProvider(): void {
|
||||
_provider = null;
|
||||
}
|
||||
@ -1,45 +1,36 @@
|
||||
/**
|
||||
* Comments repository — Cosmos DB CRUD.
|
||||
* Comments repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { CommentDoc } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('tracker_comments');
|
||||
function collection() {
|
||||
return getCollection<CommentDoc>('tracker_comments', '/id');
|
||||
}
|
||||
|
||||
export async function listByItem(itemId: string): Promise<CommentDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<CommentDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.itemId = @itemId ORDER BY c.createdAt ASC',
|
||||
parameters: [{ name: '@itemId', value: itemId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return collection().findMany({
|
||||
filter: { itemId },
|
||||
sort: { createdAt: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getById(id: string): Promise<CommentDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<CommentDoc>();
|
||||
return resource ?? null;
|
||||
return await collection().findById(id, id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(doc: CommentDoc): Promise<CommentDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as CommentDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function update(id: string, updates: Partial<CommentDoc>): Promise<CommentDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, id).read<CommentDoc>();
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
const { resource } = await container().item(id, id).replace(merged);
|
||||
return resource as CommentDoc;
|
||||
return await collection().update(id, id, { ...updates, updatedAt: new Date().toISOString() });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -47,7 +38,7 @@ export async function update(id: string, updates: Partial<CommentDoc>): Promise<
|
||||
|
||||
export async function remove(id: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, id).delete();
|
||||
await collection().delete(id, id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@ -55,11 +46,5 @@ export async function remove(id: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function countByItem(itemId: string): Promise<number> {
|
||||
const { resources } = await container()
|
||||
.items.query<number>({
|
||||
query: 'SELECT VALUE COUNT(1) FROM c WHERE c.itemId = @itemId',
|
||||
parameters: [{ name: '@itemId', value: itemId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? 0;
|
||||
return collection().count({ itemId });
|
||||
}
|
||||
|
||||
@ -1,40 +1,29 @@
|
||||
/**
|
||||
* Feature flags repository — Cosmos DB CRUD.
|
||||
* Feature flags repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { FeatureFlagDoc } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('feature_flags');
|
||||
function collection() {
|
||||
return getCollection<FeatureFlagDoc>('feature_flags', '/productId');
|
||||
}
|
||||
|
||||
export async function list(productId: string): Promise<FeatureFlagDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<FeatureFlagDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.key ASC',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return collection().findMany({
|
||||
filter: { productId },
|
||||
sort: { key: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getByKey(key: string, productId: string): Promise<FeatureFlagDoc | null> {
|
||||
const { resources } = await container()
|
||||
.items.query<FeatureFlagDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.key = @key',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@key', value: key },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return collection().findOne({
|
||||
filter: { productId, key },
|
||||
});
|
||||
}
|
||||
|
||||
export async function create(doc: FeatureFlagDoc): Promise<FeatureFlagDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as FeatureFlagDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function update(
|
||||
@ -42,11 +31,7 @@ export async function update(
|
||||
updates: Partial<FeatureFlagDoc>
|
||||
): Promise<FeatureFlagDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(id, id).read<FeatureFlagDoc>();
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
const { resource } = await container().item(id, id).replace(merged);
|
||||
return resource as FeatureFlagDoc;
|
||||
return await collection().update(id, id, { ...updates, updatedAt: new Date().toISOString() });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -54,7 +39,7 @@ export async function update(
|
||||
|
||||
export async function remove(id: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, id).delete();
|
||||
await collection().delete(id, id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@ -1,24 +1,10 @@
|
||||
/**
|
||||
* Repository tests for products module — mocked Cosmos DB.
|
||||
* Repository tests for products module — in-memory datastore.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockFetchAll = vi.fn();
|
||||
const mockCreate = vi.fn();
|
||||
const mockRead = vi.fn();
|
||||
const mockReplace = vi.fn();
|
||||
|
||||
vi.mock('../../lib/cosmos.js', () => ({
|
||||
getContainer: vi.fn(() => ({
|
||||
items: {
|
||||
query: () => ({ fetchAll: mockFetchAll }),
|
||||
create: mockCreate,
|
||||
},
|
||||
item: () => ({ read: mockRead, replace: mockReplace }),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||
import { setProvider, _resetDatastoreProvider } from '../../lib/datastore.js';
|
||||
import { getAll, getById, create, update } from './repository.js';
|
||||
import type { ProductDoc } from './types.js';
|
||||
|
||||
@ -37,20 +23,27 @@ const baseProduct: ProductDoc = {
|
||||
updatedAt: '2026-02-16T00:00:00Z',
|
||||
};
|
||||
|
||||
let memProvider: MemoryDatastoreProvider;
|
||||
|
||||
describe('products repository', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
memProvider = new MemoryDatastoreProvider();
|
||||
setProvider(memProvider);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
_resetDatastoreProvider();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('returns all products', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseProduct] });
|
||||
await create(baseProduct);
|
||||
const result = await getAll();
|
||||
expect(result).toEqual([baseProduct]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe('lysnrai');
|
||||
});
|
||||
|
||||
it('returns empty array when no products', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await getAll();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@ -58,27 +51,19 @@ describe('products repository', () => {
|
||||
|
||||
describe('getById', () => {
|
||||
it('returns product when found', async () => {
|
||||
mockRead.mockResolvedValue({ resource: baseProduct });
|
||||
await create(baseProduct);
|
||||
const result = await getById('lysnrai');
|
||||
expect(result).toEqual(baseProduct);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockRead.mockRejectedValue(new Error('Not found'));
|
||||
const result = await getById('nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when resource is undefined', async () => {
|
||||
mockRead.mockResolvedValue({ resource: undefined });
|
||||
const result = await getById('lysnrai');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates and returns product', async () => {
|
||||
mockCreate.mockResolvedValue({ resource: baseProduct });
|
||||
const result = await create(baseProduct);
|
||||
expect(result).toEqual(baseProduct);
|
||||
});
|
||||
@ -86,21 +71,14 @@ describe('products repository', () => {
|
||||
|
||||
describe('update', () => {
|
||||
it('merges updates and returns product', async () => {
|
||||
mockRead.mockResolvedValue({ resource: baseProduct });
|
||||
const updated = { ...baseProduct, displayName: 'LysnrAI Pro' };
|
||||
mockReplace.mockResolvedValue({ resource: updated });
|
||||
await create(baseProduct);
|
||||
const result = await update('lysnrai', { displayName: 'LysnrAI Pro' });
|
||||
expect(result).toEqual(updated);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.displayName).toBe('LysnrAI Pro');
|
||||
expect(result!.id).toBe('lysnrai');
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockRead.mockResolvedValue({ resource: undefined });
|
||||
const result = await update('nonexistent', { displayName: 'Test' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on error', async () => {
|
||||
mockRead.mockRejectedValue(new Error('Not found'));
|
||||
const result = await update('nonexistent', { displayName: 'Test' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@ -1,36 +1,31 @@
|
||||
/**
|
||||
* Products repository — Cosmos DB CRUD.
|
||||
* Products repository — cloud-agnostic via @bytelyst/datastore.
|
||||
* Products are the central registry; productId is the partition key.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { ProductDoc } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('products');
|
||||
function collection() {
|
||||
return getCollection<ProductDoc>('products', '/productId');
|
||||
}
|
||||
|
||||
export async function getAll(): Promise<ProductDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<ProductDoc>({
|
||||
query: 'SELECT * FROM c ORDER BY c.displayName ASC',
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return collection().findMany({
|
||||
sort: { displayName: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getById(productId: string): Promise<ProductDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(productId, productId).read<ProductDoc>();
|
||||
return resource ?? null;
|
||||
return await collection().findById(productId, productId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(doc: ProductDoc): Promise<ProductDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as ProductDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function update(
|
||||
@ -38,11 +33,10 @@ export async function update(
|
||||
updates: Partial<ProductDoc>
|
||||
): Promise<ProductDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(productId, productId).read<ProductDoc>();
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
const { resource } = await container().item(productId, productId).replace(merged);
|
||||
return resource as ProductDoc;
|
||||
return await collection().update(productId, productId, {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,19 +1,10 @@
|
||||
/**
|
||||
* Repository tests for settings module — mocked Cosmos DB.
|
||||
* Repository tests for settings module — in-memory datastore.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockRead = vi.fn();
|
||||
const mockUpsert = vi.fn();
|
||||
|
||||
vi.mock('../../lib/cosmos.js', () => ({
|
||||
getContainer: vi.fn(() => ({
|
||||
items: { upsert: mockUpsert },
|
||||
item: () => ({ read: mockRead }),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||
import { setProvider, _resetDatastoreProvider } from '../../lib/datastore.js';
|
||||
import { getSettingsId, getByUserId, upsert } from './repository.js';
|
||||
import type { UserSettingsDoc } from './types.js';
|
||||
|
||||
@ -29,7 +20,11 @@ const baseSettings: UserSettingsDoc = {
|
||||
|
||||
describe('settings repository', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setProvider(new MemoryDatastoreProvider());
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
_resetDatastoreProvider();
|
||||
});
|
||||
|
||||
describe('getSettingsId', () => {
|
||||
@ -44,19 +39,12 @@ describe('settings repository', () => {
|
||||
|
||||
describe('getByUserId', () => {
|
||||
it('returns settings when found', async () => {
|
||||
mockRead.mockResolvedValue({ resource: baseSettings });
|
||||
await upsert(baseSettings);
|
||||
const result = await getByUserId('user_1', 'lysnrai');
|
||||
expect(result).toEqual(baseSettings);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockRead.mockRejectedValue(new Error('Not found'));
|
||||
const result = await getByUserId('user_1', 'lysnrai');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when resource is undefined', async () => {
|
||||
mockRead.mockResolvedValue({ resource: undefined });
|
||||
const result = await getByUserId('user_1', 'lysnrai');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -64,7 +52,6 @@ describe('settings repository', () => {
|
||||
|
||||
describe('upsert', () => {
|
||||
it('upserts and returns settings', async () => {
|
||||
mockUpsert.mockResolvedValue({ resource: baseSettings });
|
||||
const result = await upsert(baseSettings);
|
||||
expect(result).toEqual(baseSettings);
|
||||
});
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
/**
|
||||
* User settings repository — Cosmos DB CRUD for settings + device overrides.
|
||||
* User settings repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { UserSettingsDoc } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('settings');
|
||||
function collection() {
|
||||
return getCollection<UserSettingsDoc>('settings', '/userId');
|
||||
}
|
||||
|
||||
export function getSettingsId(productId: string, userId: string): string {
|
||||
@ -19,14 +19,12 @@ export async function getByUserId(
|
||||
): Promise<UserSettingsDoc | null> {
|
||||
const id = getSettingsId(productId, userId);
|
||||
try {
|
||||
const { resource } = await container().item(id, userId).read<UserSettingsDoc>();
|
||||
return resource ?? null;
|
||||
return await collection().findById(id, userId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsert(doc: UserSettingsDoc): Promise<UserSettingsDoc> {
|
||||
const { resource } = await container().items.upsert<UserSettingsDoc>(doc);
|
||||
return resource!;
|
||||
return collection().upsert(doc);
|
||||
}
|
||||
|
||||
@ -1,65 +1,53 @@
|
||||
/**
|
||||
* Theme repository — Cosmos DB.
|
||||
* Theme repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { ThemeDoc } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('themes');
|
||||
function collection() {
|
||||
return getCollection<ThemeDoc>('themes', '/id');
|
||||
}
|
||||
|
||||
export async function getAll(productId: string): Promise<ThemeDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<ThemeDoc>({
|
||||
query:
|
||||
"SELECT * FROM c WHERE c.productId = @productId AND c.type = 'theme' ORDER BY c.created_at DESC",
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return collection().findMany({
|
||||
filter: { productId, type: 'theme' },
|
||||
sort: { created_at: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getById(id: string): Promise<ThemeDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<ThemeDoc>();
|
||||
return resource ?? null;
|
||||
return await collection().findById(id, id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActive(productId: string): Promise<ThemeDoc | null> {
|
||||
const { resources } = await container()
|
||||
.items.query<ThemeDoc>({
|
||||
query:
|
||||
"SELECT * FROM c WHERE c.productId = @productId AND c.type = 'theme' AND c.is_active = true ORDER BY c.is_default DESC, c.created_at DESC OFFSET 0 LIMIT 1",
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return collection().findOne({
|
||||
filter: { productId, type: 'theme', is_active: true },
|
||||
sort: { is_default: -1, created_at: -1 },
|
||||
limit: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export async function create(theme: ThemeDoc): Promise<ThemeDoc> {
|
||||
const { resource } = await container().items.create(theme);
|
||||
return resource as ThemeDoc;
|
||||
return collection().create(theme);
|
||||
}
|
||||
|
||||
export async function update(
|
||||
id: string,
|
||||
updates: Record<string, unknown>
|
||||
): Promise<ThemeDoc | null> {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const merged = {
|
||||
...existing,
|
||||
...updates,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { resource } = await container().item(id, id).replace(merged);
|
||||
return resource as ThemeDoc;
|
||||
try {
|
||||
return await collection().update(id, id, {
|
||||
...updates,
|
||||
updated_at: new Date().toISOString(),
|
||||
} as Partial<ThemeDoc>);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setActive(id: string, productId: string): Promise<boolean> {
|
||||
@ -67,25 +55,15 @@ export async function setActive(id: string, productId: string): Promise<boolean>
|
||||
if (!theme) return false;
|
||||
|
||||
// Deactivate all others for this product
|
||||
const { resources: activeThemes } = await container()
|
||||
.items.query<ThemeDoc>({
|
||||
query:
|
||||
"SELECT * FROM c WHERE c.productId = @productId AND c.type = 'theme' AND c.is_active = true AND c.id != @excludeId",
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@excludeId', value: id },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
const activeThemes = await collection().findMany({
|
||||
filter: { productId, type: 'theme', is_active: true, id: { $ne: id } },
|
||||
});
|
||||
|
||||
for (const item of activeThemes) {
|
||||
await container()
|
||||
.item(item.id, item.id)
|
||||
.replace({
|
||||
...item,
|
||||
is_active: false,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
await collection().update(item.id, item.id, {
|
||||
is_active: false,
|
||||
updated_at: new Date().toISOString(),
|
||||
} as Partial<ThemeDoc>);
|
||||
}
|
||||
|
||||
// Activate the target theme
|
||||
@ -95,7 +73,7 @@ export async function setActive(id: string, productId: string): Promise<boolean>
|
||||
|
||||
export async function remove(id: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, id).delete();
|
||||
await collection().delete(id, id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@ -1,23 +1,10 @@
|
||||
/**
|
||||
* Repository tests for votes module — mocked Cosmos DB.
|
||||
* Repository tests for votes module — in-memory datastore.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockFetchAll = vi.fn();
|
||||
const mockCreate = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock('../../lib/cosmos.js', () => ({
|
||||
getContainer: vi.fn(() => ({
|
||||
items: {
|
||||
query: () => ({ fetchAll: mockFetchAll }),
|
||||
create: mockCreate,
|
||||
},
|
||||
item: () => ({ delete: mockDelete }),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
||||
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||
import { setProvider, _resetDatastoreProvider } from '../../lib/datastore.js';
|
||||
import { getByItemAndUser, countByItem, listByItem, create, remove } from './repository.js';
|
||||
import type { VoteDoc } from './types.js';
|
||||
|
||||
@ -31,18 +18,21 @@ const baseVote: VoteDoc = {
|
||||
|
||||
describe('votes repository', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setProvider(new MemoryDatastoreProvider());
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
_resetDatastoreProvider();
|
||||
});
|
||||
|
||||
describe('getByItemAndUser', () => {
|
||||
it('returns vote when found', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseVote] });
|
||||
await create(baseVote);
|
||||
const result = await getByItemAndUser('item_1', 'user_1');
|
||||
expect(result).toEqual(baseVote);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await getByItemAndUser('item_1', 'user_1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@ -50,13 +40,13 @@ describe('votes repository', () => {
|
||||
|
||||
describe('countByItem', () => {
|
||||
it('returns count', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [5] });
|
||||
await create(baseVote);
|
||||
await create({ ...baseVote, id: 'vote_2', userId: 'user_2' });
|
||||
const result = await countByItem('item_1');
|
||||
expect(result).toBe(5);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 when no votes', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await countByItem('item_1');
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
@ -64,13 +54,13 @@ describe('votes repository', () => {
|
||||
|
||||
describe('listByItem', () => {
|
||||
it('returns votes for item', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [baseVote] });
|
||||
await create(baseVote);
|
||||
const result = await listByItem('item_1');
|
||||
expect(result).toEqual([baseVote]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.id).toBe('vote_1');
|
||||
});
|
||||
|
||||
it('returns empty array when no votes', async () => {
|
||||
mockFetchAll.mockResolvedValue({ resources: [] });
|
||||
const result = await listByItem('item_1');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@ -78,7 +68,6 @@ describe('votes repository', () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('creates and returns vote', async () => {
|
||||
mockCreate.mockResolvedValue({ resource: baseVote });
|
||||
const result = await create(baseVote);
|
||||
expect(result).toEqual(baseVote);
|
||||
});
|
||||
@ -86,15 +75,14 @@ describe('votes repository', () => {
|
||||
|
||||
describe('remove', () => {
|
||||
it('deletes and returns true', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await create(baseVote);
|
||||
const result = await remove('vote_1');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on error', async () => {
|
||||
mockDelete.mockRejectedValue(new Error('Not found'));
|
||||
const result = await remove('vote_1');
|
||||
expect(result).toBe(false);
|
||||
it('returns true even for nonexistent (idempotent delete)', async () => {
|
||||
const result = await remove('nonexistent');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,55 +1,38 @@
|
||||
/**
|
||||
* Votes repository — Cosmos DB CRUD.
|
||||
* Votes repository — cloud-agnostic via @bytelyst/datastore.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type { VoteDoc } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('tracker_votes');
|
||||
function collection() {
|
||||
return getCollection<VoteDoc>('tracker_votes', '/id');
|
||||
}
|
||||
|
||||
export async function getByItemAndUser(itemId: string, userId: string): Promise<VoteDoc | null> {
|
||||
const { resources } = await container()
|
||||
.items.query<VoteDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.itemId = @itemId AND c.userId = @userId',
|
||||
parameters: [
|
||||
{ name: '@itemId', value: itemId },
|
||||
{ name: '@userId', value: userId },
|
||||
],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
return collection().findOne({
|
||||
filter: { itemId, userId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function countByItem(itemId: string): Promise<number> {
|
||||
const { resources } = await container()
|
||||
.items.query<number>({
|
||||
query: 'SELECT VALUE COUNT(1) FROM c WHERE c.itemId = @itemId',
|
||||
parameters: [{ name: '@itemId', value: itemId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources[0] ?? 0;
|
||||
return collection().count({ itemId });
|
||||
}
|
||||
|
||||
export async function listByItem(itemId: string): Promise<VoteDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<VoteDoc>({
|
||||
query: 'SELECT * FROM c WHERE c.itemId = @itemId ORDER BY c.createdAt DESC',
|
||||
parameters: [{ name: '@itemId', value: itemId }],
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
return collection().findMany({
|
||||
filter: { itemId },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function create(doc: VoteDoc): Promise<VoteDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as VoteDoc;
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function remove(id: string): Promise<boolean> {
|
||||
try {
|
||||
await container().item(id, id).delete();
|
||||
await collection().delete(id, id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user