refactor(platform-service): migrate remaining 14 repositories to @bytelyst/datastore

Migrated modules: audit, auth, invitations, items, jobs, licenses,
maintenance, notifications, subscriptions, telemetry, tokens, usage,
waitlist, webhooks.

Updated 4 test files (notifications, subscriptions, tokens, usage) from
Cosmos SDK mocks to MemoryDatastoreProvider.

Zero cosmos.js imports remain in modules/. All 66 test files pass (746 tests).
This commit is contained in:
saravanakumardb1 2026-03-02 01:06:24 -08:00
parent e355cb0c1b
commit b69abf44c7
18 changed files with 576 additions and 1147 deletions

View File

@ -1,74 +1,58 @@
/** /**
* Audit repository Cosmos DB CRUD. * Audit repository cloud-agnostic via @bytelyst/datastore.
*/ */
import { getContainer } from '../../lib/cosmos.js'; import type { FilterMap } from '@bytelyst/datastore';
import { getCollection } from '../../lib/datastore.js';
import type { AuditDoc, QueryAuditInput } from './types.js'; import type { AuditDoc, QueryAuditInput } from './types.js';
// Default TTL: 90 days in seconds // Default TTL: 90 days in seconds
const DEFAULT_TTL = 90 * 24 * 60 * 60; const DEFAULT_TTL = 90 * 24 * 60 * 60;
function container() { function collection() {
return getContainer('audit_log'); return getCollection<AuditDoc>('audit_log', '/productId');
} }
export async function create(doc: AuditDoc): Promise<AuditDoc> { export async function create(doc: AuditDoc): Promise<AuditDoc> {
const { resource } = await container().items.create({ return collection().create({
...doc, ...doc,
ttl: doc.ttl ?? DEFAULT_TTL, ttl: doc.ttl ?? DEFAULT_TTL,
}); });
return resource as AuditDoc;
} }
export async function query(input: QueryAuditInput, productId: string): Promise<AuditDoc[]> { export async function query(input: QueryAuditInput, productId: string): Promise<AuditDoc[]> {
const { userId, action, category, days, limit, offset } = input; const { userId, action, category, days, limit, offset } = input;
const since = new Date(Date.now() - days * 86400000).toISOString(); const since = new Date(Date.now() - days * 86400000).toISOString();
let queryText = 'SELECT * FROM c WHERE c.productId = @productId AND c.createdAt >= @since'; const filter: FilterMap = {
const parameters: { name: string; value: string | number }[] = [ productId,
{ name: '@productId', value: productId }, createdAt: { $gte: since },
{ name: '@since', value: since }, };
]; if (userId) filter.userId = userId;
if (action) filter.action = action;
if (category) filter.category = category;
if (userId) { const results = await collection().findMany({
queryText += ' AND c.userId = @userId'; filter,
parameters.push({ name: '@userId', value: userId }); sort: { createdAt: -1 },
} limit: limit + offset,
if (action) { });
queryText += ' AND c.action = @action'; return results.slice(offset);
parameters.push({ name: '@action', value: action });
}
if (category) {
queryText += ' AND c.category = @category';
parameters.push({ name: '@category', value: category });
}
queryText += ' ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit';
parameters.push({ name: '@offset', value: offset });
parameters.push({ name: '@limit', value: limit });
const { resources } = await container()
.items.query<AuditDoc>({ query: queryText, parameters })
.fetchAll();
return resources;
} }
export async function getStats(days = 30, productId?: string): Promise<Record<string, number>> { export async function getStats(days = 30, productId?: string): Promise<Record<string, number>> {
const since = new Date(Date.now() - days * 86400000).toISOString(); const since = new Date(Date.now() - days * 86400000).toISOString();
const { resources } = await container() // Fetch docs and aggregate in-memory
.items.query<{ action: string; count: number }>({ const docs = await collection().findMany({
query: filter: {
'SELECT c.action, COUNT(1) as count FROM c WHERE c.productId = @productId AND c.createdAt >= @since GROUP BY c.action', productId: productId ?? '',
parameters: [ createdAt: { $gte: since },
{ name: '@productId', value: productId ?? '' }, },
{ name: '@since', value: since }, });
],
})
.fetchAll();
const stats: Record<string, number> = {}; const stats: Record<string, number> = {};
for (const r of resources) { for (const doc of docs) {
stats[r.action] = r.count; stats[doc.action] = (stats[doc.action] ?? 0) + 1;
} }
return stats; return stats;
} }

View File

@ -1,41 +1,32 @@
/** /**
* Auth user repository Cosmos DB. * Auth user repository cloud-agnostic via @bytelyst/datastore.
*/ */
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type { UserDoc, PasswordResetTokenDoc, EmailVerificationDoc } from './types.js'; import type { UserDoc, PasswordResetTokenDoc, EmailVerificationDoc } from './types.js';
function container() { function usersCollection() {
return getContainer('users'); return getCollection<UserDoc>('users', '/id');
} }
export async function getByEmail(email: string, productId: string): Promise<UserDoc | null> { export async function getByEmail(email: string, productId: string): Promise<UserDoc | null> {
const { resources } = await container() return usersCollection().findOne({
.items.query<UserDoc>({ filter: { productId, email: email.toLowerCase() },
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.email = @email', });
parameters: [
{ name: '@productId', value: productId },
{ name: '@email', value: email.toLowerCase() },
],
})
.fetchAll();
return resources[0] ?? null;
} }
export async function getById(id: string): Promise<UserDoc | null> { export async function getById(id: string): Promise<UserDoc | null> {
try { try {
const { resource } = await container().item(id, id).read<UserDoc>(); return await usersCollection().findById(id, id);
return resource ?? null;
} catch { } catch {
return null; return null;
} }
} }
export async function create(user: UserDoc): Promise<UserDoc> { export async function create(user: UserDoc): Promise<UserDoc> {
const { resource } = await container().items.create(user); return usersCollection().create(user);
return resource as UserDoc;
} }
export async function updatePlan( export async function updatePlan(
@ -44,17 +35,12 @@ export async function updatePlan(
plan: UserDoc['plan'] plan: UserDoc['plan']
): Promise<UserDoc | null> { ): Promise<UserDoc | null> {
try { try {
const { resource } = await container().item(id, id).read<UserDoc>(); const existing = await usersCollection().findById(id, id);
if (!resource || resource.productId !== productId) return null; if (!existing || existing.productId !== productId) return null;
const { resource: updated } = await container() return await usersCollection().update(id, id, {
.item(id, id) plan,
.replace<UserDoc>({ updatedAt: new Date().toISOString(),
...resource, } as Partial<UserDoc>);
plan,
updatedAt: new Date().toISOString(),
});
if (!updated) return null;
return updated;
} catch { } catch {
return null; return null;
} }
@ -62,16 +48,11 @@ export async function updatePlan(
export async function updateLastLogin(id: string): Promise<void> { export async function updateLastLogin(id: string): Promise<void> {
try { try {
const { resource } = await container().item(id, id).read<UserDoc>(); const now = new Date().toISOString();
if (resource) { await usersCollection().update(id, id, {
await container() lastLoginAt: now,
.item(id, id) updatedAt: now,
.replace({ } as Partial<UserDoc>);
...resource,
lastLoginAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
} catch { } catch {
// Non-critical — don't throw // Non-critical — don't throw
} }
@ -80,39 +61,27 @@ export async function updateLastLogin(id: string): Promise<void> {
// ── Admin user management ──────────────────────────────────── // ── Admin user management ────────────────────────────────────
export async function list(productId: string, limit = 100, offset = 0): Promise<UserDoc[]> { export async function list(productId: string, limit = 100, offset = 0): Promise<UserDoc[]> {
const { resources } = await container() const results = await usersCollection().findMany({
.items.query<UserDoc>({ filter: { productId },
query: sort: { createdAt: -1 },
'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit', limit: limit + offset,
parameters: [ });
{ name: '@productId', value: productId }, return results.slice(offset);
{ name: '@offset', value: offset },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources;
} }
export async function count(productId: string): Promise<number> { export async function count(productId: string): Promise<number> {
const { resources } = await container() return usersCollection().count({ productId });
.items.query<number>({
query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId',
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
return resources[0] ?? 0;
} }
export async function countByPlan(productId: string): Promise<Record<string, number>> { export async function countByPlan(productId: string): Promise<Record<string, number>> {
const { resources } = await container() // Fetch all users and aggregate in-memory
.items.query<{ plan: string; cnt: number }>({ const docs = await usersCollection().findMany({
query: 'SELECT c.plan, COUNT(1) AS cnt FROM c WHERE c.productId = @productId GROUP BY c.plan', filter: { productId },
parameters: [{ name: '@productId', value: productId }], });
})
.fetchAll();
const result: Record<string, number> = {}; const result: Record<string, number> = {};
for (const r of resources) result[r.plan] = r.cnt; for (const doc of docs) {
result[doc.plan] = (result[doc.plan] ?? 0) + 1;
}
return result; return result;
} }
@ -123,15 +92,10 @@ export async function update(
> >
): Promise<UserDoc | null> { ): Promise<UserDoc | null> {
try { try {
const { resource } = await container().item(id, id).read<UserDoc>(); return await usersCollection().update(id, id, {
if (!resource) return null;
const merged = {
...resource,
...updates, ...updates,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; } as Partial<UserDoc>);
const { resource: updated } = await container().item(id, id).replace<UserDoc>(merged);
return updated ?? null;
} catch { } catch {
return null; return null;
} }
@ -139,7 +103,7 @@ export async function update(
export async function remove(id: string): Promise<boolean> { export async function remove(id: string): Promise<boolean> {
try { try {
await container().item(id, id).delete(); await usersCollection().delete(id, id);
return true; return true;
} catch { } catch {
return false; return false;
@ -156,15 +120,10 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
export async function updatePassword(id: string, newPasswordHash: string): Promise<boolean> { export async function updatePassword(id: string, newPasswordHash: string): Promise<boolean> {
try { try {
const { resource } = await container().item(id, id).read<UserDoc>(); await usersCollection().update(id, id, {
if (!resource) return false; passwordHash: newPasswordHash,
await container() updatedAt: new Date().toISOString(),
.item(id, id) } as Partial<UserDoc>);
.replace({
...resource,
passwordHash: newPasswordHash,
updatedAt: new Date().toISOString(),
});
return true; return true;
} catch { } catch {
return false; return false;
@ -173,15 +132,10 @@ export async function updatePassword(id: string, newPasswordHash: string): Promi
export async function setEmailVerified(id: string, verified: boolean): Promise<boolean> { export async function setEmailVerified(id: string, verified: boolean): Promise<boolean> {
try { try {
const { resource } = await container().item(id, id).read<UserDoc>(); await usersCollection().update(id, id, {
if (!resource) return false; emailVerified: verified,
await container() updatedAt: new Date().toISOString(),
.item(id, id) } as Partial<UserDoc>);
.replace({
...resource,
emailVerified: verified,
updatedAt: new Date().toISOString(),
});
return true; return true;
} catch { } catch {
return false; return false;
@ -190,8 +144,8 @@ export async function setEmailVerified(id: string, verified: boolean): Promise<b
// ── Password Reset Tokens ──────────────────────────────────── // ── Password Reset Tokens ────────────────────────────────────
function resetTokensContainer() { function resetTokensCollection() {
return getContainer('password_reset_tokens'); return getCollection<PasswordResetTokenDoc>('password_reset_tokens', '/productId');
} }
export function hashToken(token: string): string { export function hashToken(token: string): string {
@ -199,87 +153,55 @@ export function hashToken(token: string): string {
} }
export async function createResetToken(doc: PasswordResetTokenDoc): Promise<PasswordResetTokenDoc> { export async function createResetToken(doc: PasswordResetTokenDoc): Promise<PasswordResetTokenDoc> {
const { resource } = await resetTokensContainer().items.create(doc); return resetTokensCollection().create(doc);
return resource as PasswordResetTokenDoc;
} }
export async function findResetToken( export async function findResetToken(
tokenHash: string, tokenHash: string,
productId: string productId: string
): Promise<PasswordResetTokenDoc | null> { ): Promise<PasswordResetTokenDoc | null> {
const { resources } = await resetTokensContainer() return resetTokensCollection().findOne({
.items.query<PasswordResetTokenDoc>( filter: { productId, tokenHash, usedAt: { $exists: false } },
{ });
query:
'SELECT * FROM c WHERE c.productId = @productId AND c.tokenHash = @tokenHash AND NOT IS_DEFINED(c.usedAt)',
parameters: [
{ name: '@productId', value: productId },
{ name: '@tokenHash', value: tokenHash },
],
},
{ partitionKey: productId }
)
.fetchAll();
return resources[0] ?? null;
} }
export async function markResetTokenUsed(id: string, productId: string): Promise<void> { export async function markResetTokenUsed(id: string, productId: string): Promise<void> {
const { resource } = await resetTokensContainer() try {
.item(id, productId) await resetTokensCollection().update(id, productId, {
.read<PasswordResetTokenDoc>(); usedAt: new Date().toISOString(),
if (resource) { } as Partial<PasswordResetTokenDoc>);
await resetTokensContainer() } catch {
.item(id, productId) // best-effort
.replace({
...resource,
usedAt: new Date().toISOString(),
});
} }
} }
// ── Email Verification Tokens ──────────────────────────────── // ── Email Verification Tokens ────────────────────────────────
function emailVerificationsContainer() { function emailVerificationsCollection() {
return getContainer('email_verifications'); return getCollection<EmailVerificationDoc>('email_verifications', '/productId');
} }
export async function createEmailVerification( export async function createEmailVerification(
doc: EmailVerificationDoc doc: EmailVerificationDoc
): Promise<EmailVerificationDoc> { ): Promise<EmailVerificationDoc> {
const { resource } = await emailVerificationsContainer().items.create(doc); return emailVerificationsCollection().create(doc);
return resource as EmailVerificationDoc;
} }
export async function findEmailVerification( export async function findEmailVerification(
tokenHash: string, tokenHash: string,
productId: string productId: string
): Promise<EmailVerificationDoc | null> { ): Promise<EmailVerificationDoc | null> {
const { resources } = await emailVerificationsContainer() return emailVerificationsCollection().findOne({
.items.query<EmailVerificationDoc>( filter: { productId, tokenHash, verifiedAt: { $exists: false } },
{ });
query:
'SELECT * FROM c WHERE c.productId = @productId AND c.tokenHash = @tokenHash AND NOT IS_DEFINED(c.verifiedAt)',
parameters: [
{ name: '@productId', value: productId },
{ name: '@tokenHash', value: tokenHash },
],
},
{ partitionKey: productId }
)
.fetchAll();
return resources[0] ?? null;
} }
export async function markEmailVerified(id: string, productId: string): Promise<void> { export async function markEmailVerified(id: string, productId: string): Promise<void> {
const { resource } = await emailVerificationsContainer() try {
.item(id, productId) await emailVerificationsCollection().update(id, productId, {
.read<EmailVerificationDoc>(); verifiedAt: new Date().toISOString(),
if (resource) { } as Partial<EmailVerificationDoc>);
await emailVerificationsContainer() } catch {
.item(id, productId) // best-effort
.replace({
...resource,
verifiedAt: new Date().toISOString(),
});
} }
} }

View File

@ -1,15 +1,13 @@
/** /**
* Invitations repository Cosmos DB CRUD operations. * Invitations repository cloud-agnostic via @bytelyst/datastore.
* Consolidated from admin-dashboard-web + user-dashboard-web repos. * Consolidated from admin-dashboard-web + user-dashboard-web repos.
*/ */
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { InvitationCodeDoc } from './types.js'; import type { InvitationCodeDoc } from './types.js';
const CONTAINER = 'invitation_codes'; function collection() {
return getCollection<InvitationCodeDoc>('invitation_codes', '/id');
function container() {
return getContainer(CONTAINER);
} }
export async function list( export async function list(
@ -17,24 +15,17 @@ export async function list(
offset = 0, offset = 0,
productId?: string productId?: string
): Promise<InvitationCodeDoc[]> { ): Promise<InvitationCodeDoc[]> {
const { resources } = await container() const results = await collection().findMany({
.items.query<InvitationCodeDoc>({ filter: { productId: productId ?? '' },
query: sort: { createdAt: -1 },
'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit', limit: limit + offset,
parameters: [ });
{ name: '@productId', value: productId ?? '' }, return results.slice(offset);
{ name: '@offset', value: offset },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources;
} }
export async function getById(id: string): Promise<InvitationCodeDoc | null> { export async function getById(id: string): Promise<InvitationCodeDoc | null> {
try { try {
const { resource } = await container().item(id, id).read<InvitationCodeDoc>(); return await collection().findById(id, id);
return resource ?? null;
} catch { } catch {
return null; return null;
} }
@ -44,21 +35,13 @@ export async function getByCode(
code: string, code: string,
productId?: string productId?: string
): Promise<InvitationCodeDoc | null> { ): Promise<InvitationCodeDoc | null> {
const { resources } = await container() return collection().findOne({
.items.query<InvitationCodeDoc>({ filter: { productId: productId ?? '', code: code.toUpperCase() },
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.code = @code', });
parameters: [
{ name: '@productId', value: productId ?? '' },
{ name: '@code', value: code.toUpperCase() },
],
})
.fetchAll();
return resources[0] ?? null;
} }
export async function create(doc: InvitationCodeDoc): Promise<InvitationCodeDoc> { export async function create(doc: InvitationCodeDoc): Promise<InvitationCodeDoc> {
const { resource } = await container().items.create(doc); return collection().create(doc);
return resource as InvitationCodeDoc;
} }
export async function update( export async function update(
@ -66,11 +49,7 @@ export async function update(
updates: Partial<InvitationCodeDoc> updates: Partial<InvitationCodeDoc>
): Promise<InvitationCodeDoc | null> { ): Promise<InvitationCodeDoc | null> {
try { try {
const { resource: existing } = await container().item(id, id).read<InvitationCodeDoc>(); return await collection().update(id, id, { ...updates, updatedAt: new Date().toISOString() });
if (!existing) return null;
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await container().item(id, id).replace(merged);
return resource as InvitationCodeDoc;
} catch { } catch {
return null; return null;
} }
@ -93,7 +72,7 @@ export async function redeem(code: string, userId: string): Promise<InvitationCo
export async function remove(id: string): Promise<boolean> { export async function remove(id: string): Promise<boolean> {
try { try {
await container().item(id, id).delete(); await collection().delete(id, id);
return true; return true;
} catch { } catch {
return false; return false;
@ -101,11 +80,5 @@ export async function remove(id: string): Promise<boolean> {
} }
export async function count(productId?: string): Promise<number> { export async function count(productId?: string): Promise<number> {
const { resources } = await container() return collection().count({ productId: productId ?? '' });
.items.query<number>({
query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId',
parameters: [{ name: '@productId', value: productId ?? '' }],
})
.fetchAll();
return resources[0] ?? 0;
} }

View File

@ -1,97 +1,66 @@
/** /**
* Tracker items repository Cosmos DB CRUD. * Tracker items repository cloud-agnostic via @bytelyst/datastore.
*/ */
import { getContainer } from '../../lib/cosmos.js'; import type { FilterMap } from '@bytelyst/datastore';
import { getCollection } from '../../lib/datastore.js';
import type { TrackerItemDoc, ListItemsQuery } from './types.js'; import type { TrackerItemDoc, ListItemsQuery } from './types.js';
function container() { function collection() {
return getContainer('tracker_items'); return getCollection<TrackerItemDoc>('tracker_items', '/id');
} }
export async function list( export async function list(
query: ListItemsQuery query: ListItemsQuery
): Promise<{ items: TrackerItemDoc[]; total: number }> { ): Promise<{ items: TrackerItemDoc[]; total: number }> {
const conditions: string[] = []; const filter: FilterMap = {};
const params: { name: string; value: string | number }[] = [];
if (query.productId) { if (query.productId) filter.productId = query.productId;
conditions.push('c.productId = @productId'); if (query.type) filter.type = query.type;
params.push({ name: '@productId', value: query.productId }); if (query.status) filter.status = query.status;
} if (query.priority) filter.priority = query.priority;
if (query.type) { if (query.assignee) filter.assignee = query.assignee;
conditions.push('c.type = @type'); if (query.visibility) filter.visibility = query.visibility;
params.push({ name: '@type', value: query.type });
} // Fetch all matching docs — label and q filtering done in-memory
if (query.status) { const sortField = query.sortBy === 'priority' ? 'priorityOrder' : query.sortBy;
conditions.push('c.status = @status'); const sortDir = query.sortOrder === 'desc' ? -1 : 1;
params.push({ name: '@status', value: query.status });
} let allDocs = await collection().findMany({
if (query.priority) { filter,
conditions.push('c.priority = @priority'); sort: { [sortField]: sortDir },
params.push({ name: '@priority', value: query.priority }); });
}
if (query.assignee) { // In-memory label filter (ARRAY_CONTAINS)
conditions.push('c.assignee = @assignee');
params.push({ name: '@assignee', value: query.assignee });
}
if (query.label) { if (query.label) {
conditions.push('ARRAY_CONTAINS(c.labels, @label)'); const label = query.label;
params.push({ name: '@label', value: query.label }); allDocs = allDocs.filter(d => d.labels?.includes(label));
}
if (query.visibility) {
conditions.push('c.visibility = @visibility');
params.push({ name: '@visibility', value: query.visibility });
} }
// In-memory text search
if (query.q) { if (query.q) {
conditions.push( const q = query.q.toLowerCase();
'(CONTAINS(LOWER(c.title), LOWER(@q)) OR CONTAINS(LOWER(c.description), LOWER(@q)))' allDocs = allDocs.filter(
d => d.title?.toLowerCase().includes(q) || d.description?.toLowerCase().includes(q)
); );
params.push({ name: '@q', value: query.q });
} }
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const total = allDocs.length;
const items = allDocs.slice(query.offset, query.offset + query.limit);
// Priority sort needs special handling — map to numeric return { items, total };
const sortField = query.sortBy === 'priority' ? 'c.priorityOrder' : `c.${query.sortBy}`;
const orderDir = query.sortOrder.toUpperCase();
// Count query
const countResult = await container()
.items.query<number>({
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
parameters: params,
})
.fetchAll();
const total = countResult.resources[0] ?? 0;
// Data query with pagination
const { resources } = await container()
.items.query<TrackerItemDoc>({
query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`,
parameters: [
...params,
{ name: '@offset', value: query.offset },
{ name: '@limit', value: query.limit },
],
})
.fetchAll();
return { items: resources, total };
} }
export async function getById(id: string): Promise<TrackerItemDoc | null> { export async function getById(id: string): Promise<TrackerItemDoc | null> {
try { try {
const { resource } = await container().item(id, id).read<TrackerItemDoc>(); return await collection().findById(id, id);
return resource ?? null;
} catch { } catch {
return null; return null;
} }
} }
export async function create(doc: TrackerItemDoc): Promise<TrackerItemDoc> { export async function create(doc: TrackerItemDoc): Promise<TrackerItemDoc> {
const { resource } = await container().items.create(doc); return collection().create(doc);
return resource as TrackerItemDoc;
} }
export async function update( export async function update(
@ -99,11 +68,7 @@ export async function update(
updates: Partial<TrackerItemDoc> updates: Partial<TrackerItemDoc>
): Promise<TrackerItemDoc | null> { ): Promise<TrackerItemDoc | null> {
try { try {
const { resource: existing } = await container().item(id, id).read<TrackerItemDoc>(); return await collection().update(id, id, { ...updates, updatedAt: new Date().toISOString() });
if (!existing) return null;
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await container().item(id, id).replace(merged);
return resource as TrackerItemDoc;
} catch { } catch {
return null; return null;
} }
@ -111,7 +76,7 @@ export async function update(
export async function remove(id: string): Promise<boolean> { export async function remove(id: string): Promise<boolean> {
try { try {
await container().item(id, id).delete(); await collection().delete(id, id);
return true; return true;
} catch { } catch {
return false; return false;

View File

@ -1,42 +1,32 @@
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import { NotFoundError } from '../../lib/errors.js'; import { NotFoundError } from '../../lib/errors.js';
import type { JobDefinitionDoc, JobRunDoc } from './types.js'; import type { JobDefinitionDoc, JobRunDoc } from './types.js';
const DEFS_CONTAINER = 'job_definitions'; function defsCollection() {
const RUNS_CONTAINER = 'job_runs'; return getCollection<JobDefinitionDoc>('job_definitions', '/productId');
function defsContainer() {
return getContainer(DEFS_CONTAINER);
} }
function runsContainer() { function runsCollection() {
return getContainer(RUNS_CONTAINER); return getCollection<JobRunDoc>('job_runs', '/pk');
} }
// ── Job Definition CRUD ────────────────────────────────────── // ── Job Definition CRUD ──────────────────────────────────────
export async function listJobDefinitions(productId: string): Promise<JobDefinitionDoc[]> { export async function listJobDefinitions(productId: string): Promise<JobDefinitionDoc[]> {
const { resources } = await defsContainer() return defsCollection().findMany({
.items.query<JobDefinitionDoc>( filter: { productId },
{ sort: { name: 1 },
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.name', });
parameters: [{ name: '@productId', value: productId }],
},
{ partitionKey: productId }
)
.fetchAll();
return resources;
} }
export async function getJobDefinition(id: string, productId: string): Promise<JobDefinitionDoc> { export async function getJobDefinition(id: string, productId: string): Promise<JobDefinitionDoc> {
const { resource } = await defsContainer().item(id, productId).read<JobDefinitionDoc>(); const doc = await defsCollection().findById(id, productId);
if (!resource) throw new NotFoundError(`Job definition '${id}' not found`); if (!doc) throw new NotFoundError(`Job definition '${id}' not found`);
return resource; return doc;
} }
export async function upsertJobDefinition(doc: JobDefinitionDoc): Promise<JobDefinitionDoc> { export async function upsertJobDefinition(doc: JobDefinitionDoc): Promise<JobDefinitionDoc> {
const { resource } = await defsContainer().items.upsert(doc); return defsCollection().upsert(doc);
return resource as unknown as JobDefinitionDoc;
} }
export async function updateJobDefinition( export async function updateJobDefinition(
@ -44,27 +34,20 @@ export async function updateJobDefinition(
productId: string, productId: string,
updates: Partial<JobDefinitionDoc> updates: Partial<JobDefinitionDoc>
): Promise<JobDefinitionDoc> { ): Promise<JobDefinitionDoc> {
const existing = await getJobDefinition(id, productId); return defsCollection().update(id, productId, {
const updated: JobDefinitionDoc = {
...existing,
...updates, ...updates,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; });
const { resource } = await defsContainer().item(id, productId).replace(updated);
return resource as JobDefinitionDoc;
} }
// ── Job Run CRUD ───────────────────────────────────────────── // ── Job Run CRUD ─────────────────────────────────────────────
export async function createJobRun(doc: JobRunDoc): Promise<JobRunDoc> { export async function createJobRun(doc: JobRunDoc): Promise<JobRunDoc> {
const { resource } = await runsContainer().items.create(doc); return runsCollection().create(doc);
return resource as JobRunDoc;
} }
export async function updateJobRun(doc: JobRunDoc): Promise<JobRunDoc> { export async function updateJobRun(doc: JobRunDoc): Promise<JobRunDoc> {
const pk = `${doc.productId}:${doc.jobName}`; return runsCollection().upsert(doc);
const { resource } = await runsContainer().item(doc.id, pk).replace(doc);
return resource as JobRunDoc;
} }
export async function listJobRuns( export async function listJobRuns(
@ -72,20 +55,9 @@ export async function listJobRuns(
jobName: string, jobName: string,
limit = 20 limit = 20
): Promise<JobRunDoc[]> { ): Promise<JobRunDoc[]> {
const pk = `${productId}:${jobName}`; return runsCollection().findMany({
const { resources } = await runsContainer() filter: { productId, jobName },
.items.query<JobRunDoc>( sort: { startedAt: -1 },
{ limit,
query: });
'SELECT TOP @limit * FROM c WHERE c.productId = @productId AND c.jobName = @jobName ORDER BY c.startedAt DESC',
parameters: [
{ name: '@productId', value: productId },
{ name: '@jobName', value: jobName },
{ name: '@limit', value: limit },
],
},
{ partitionKey: pk }
)
.fetchAll();
return resources;
} }

View File

@ -1,13 +1,13 @@
/** /**
* Licenses repository Cosmos DB CRUD. * Licenses repository cloud-agnostic via @bytelyst/datastore.
*/ */
import crypto from 'crypto'; import crypto from 'crypto';
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { LicenseDoc } from './types.js'; import type { LicenseDoc } from './types.js';
function container() { function collection() {
return getContainer('licenses'); return getCollection<LicenseDoc>('licenses', '/userId');
} }
export function generateKey(licensePrefix: string): string { export function generateKey(licensePrefix: string): string {
@ -16,35 +16,20 @@ export function generateKey(licensePrefix: string): string {
} }
export async function getByKey(key: string, productId: string): Promise<LicenseDoc | null> { export async function getByKey(key: string, productId: string): Promise<LicenseDoc | null> {
const { resources } = await container() return collection().findOne({
.items.query<LicenseDoc>({ filter: { productId, key: key.toUpperCase() },
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.key = @key', });
parameters: [
{ name: '@productId', value: productId },
{ name: '@key', value: key.toUpperCase() },
],
})
.fetchAll();
return resources[0] ?? null;
} }
export async function getByUserId(userId: string, productId: string): Promise<LicenseDoc[]> { export async function getByUserId(userId: string, productId: string): Promise<LicenseDoc[]> {
const { resources } = await container() return collection().findMany({
.items.query<LicenseDoc>({ filter: { productId, userId },
query: sort: { createdAt: -1 },
'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC', });
parameters: [
{ name: '@productId', value: productId },
{ name: '@userId', value: userId },
],
})
.fetchAll();
return resources;
} }
export async function create(doc: LicenseDoc): Promise<LicenseDoc> { export async function create(doc: LicenseDoc): Promise<LicenseDoc> {
const { resource } = await container().items.create(doc); return collection().create(doc);
return resource as LicenseDoc;
} }
export async function update( export async function update(
@ -53,11 +38,10 @@ export async function update(
updates: Partial<LicenseDoc> updates: Partial<LicenseDoc>
): Promise<LicenseDoc | null> { ): Promise<LicenseDoc | null> {
try { try {
const { resource: existing } = await container().item(id, userId).read<LicenseDoc>(); return await collection().update(id, userId, {
if (!existing) return null; ...updates,
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; updatedAt: new Date().toISOString(),
const { resource } = await container().item(id, userId).replace(merged); });
return resource as LicenseDoc;
} catch { } catch {
return null; return null;
} }

View File

@ -1,12 +1,10 @@
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { MaintenanceConfig, MaintenanceWindow } from './types.js'; import type { MaintenanceConfig, MaintenanceWindow } from './types.js';
// ── Maintenance Config ─────────────────────────────────────── // ── Maintenance Config ───────────────────────────────────────
// Stored as a single document per product in the settings container. // Stored as a single document per product in the settings container.
// Uses the existing settings container (no new container needed). // Uses the existing settings container (no new container needed).
const SETTINGS_CONTAINER = 'settings';
interface MaintenanceSettingsDoc { interface MaintenanceSettingsDoc {
id: string; id: string;
productId: string; productId: string;
@ -16,8 +14,8 @@ interface MaintenanceSettingsDoc {
_etag?: string; _etag?: string;
} }
function settingsContainer() { function settingsCollection() {
return getContainer(SETTINGS_CONTAINER); return getCollection<MaintenanceSettingsDoc>('settings', '/productId');
} }
const DEFAULT_CONFIG: MaintenanceConfig = { const DEFAULT_CONFIG: MaintenanceConfig = {
@ -36,10 +34,8 @@ function docId(productId: string): string {
export async function getMaintenanceConfig(productId: string): Promise<MaintenanceConfig> { export async function getMaintenanceConfig(productId: string): Promise<MaintenanceConfig> {
try { try {
const { resource } = await settingsContainer() const doc = await settingsCollection().findById(docId(productId), productId);
.item(docId(productId), productId) return doc?.config ?? DEFAULT_CONFIG;
.read<MaintenanceSettingsDoc>();
return resource?.config ?? DEFAULT_CONFIG;
} catch { } catch {
return DEFAULT_CONFIG; return DEFAULT_CONFIG;
} }
@ -57,54 +53,33 @@ export async function updateMaintenanceConfig(
config, config,
}; };
try { const result = await settingsCollection().upsert(doc);
const { resource: existing } = await settingsContainer().item(id, productId).read(); return result.config;
if (existing) {
const { resource } = await settingsContainer().item(id, productId).replace(doc);
return (resource as MaintenanceSettingsDoc).config;
}
} catch {
// Document doesn't exist, create it
}
const { resource } = await settingsContainer().items.create(doc);
return (resource as MaintenanceSettingsDoc).config;
} }
// ── Maintenance Windows ────────────────────────────────────── // ── Maintenance Windows ──────────────────────────────────────
const WINDOWS_CONTAINER = 'maintenance_windows'; function windowsCollection() {
return getCollection<MaintenanceWindow>('maintenance_windows', '/productId');
function windowsContainer() {
return getContainer(WINDOWS_CONTAINER);
} }
export async function listUpcomingWindows(productId: string): Promise<MaintenanceWindow[]> { export async function listUpcomingWindows(productId: string): Promise<MaintenanceWindow[]> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const { resources } = await windowsContainer() // Fetch all for this product and filter in-memory for scheduledEnd > now
.items.query<MaintenanceWindow>( const all = await windowsCollection().findMany({
{ filter: { productId },
query: sort: { scheduledStart: 1 },
'SELECT * FROM c WHERE c.productId = @productId AND c.scheduledEnd > @now ORDER BY c.scheduledStart ASC', });
parameters: [ return all.filter(w => w.scheduledEnd > now);
{ name: '@productId', value: productId },
{ name: '@now', value: now },
],
},
{ partitionKey: productId }
)
.fetchAll();
return resources;
} }
export async function createWindow(doc: MaintenanceWindow): Promise<MaintenanceWindow> { export async function createWindow(doc: MaintenanceWindow): Promise<MaintenanceWindow> {
const { resource } = await windowsContainer().items.create(doc); return windowsCollection().create(doc);
return resource as MaintenanceWindow;
} }
export async function deleteWindow(id: string, productId: string): Promise<boolean> { export async function deleteWindow(id: string, productId: string): Promise<boolean> {
try { try {
await windowsContainer().item(id, productId).delete(); await windowsCollection().delete(id, productId);
return true; return true;
} catch { } catch {
return false; return false;

View File

@ -1,27 +1,17 @@
/** /**
* Repository tests for notifications module mocked Cosmos DB. * Repository tests for notifications module in-memory datastore.
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
const mockFetchAll = vi.fn(); import { setProvider } from '../../lib/datastore.js';
const mockUpsert = vi.fn(); import {
const mockDelete = vi.fn(); getDevicesByUser,
const mockRead = vi.fn(); upsertDevice,
const mockCreate = vi.fn(); removeDevice,
getPrefs,
vi.mock('../../lib/cosmos.js', () => ({ upsertPrefs,
getContainer: vi.fn(() => ({ } from './repository.js';
items: {
query: () => ({ fetchAll: mockFetchAll }),
upsert: mockUpsert,
create: mockCreate,
},
item: () => ({ delete: mockDelete, read: mockRead }),
})),
}));
import { getDevicesByUser, upsertDevice, removeDevice, getPrefs, upsertPrefs } from './repository.js';
import type { DeviceDoc, NotificationPrefsDoc } from './types.js'; import type { DeviceDoc, NotificationPrefsDoc } from './types.js';
const baseDevice: DeviceDoc = { const baseDevice: DeviceDoc = {
@ -49,18 +39,18 @@ const basePrefs: NotificationPrefsDoc = {
describe('notifications repository', () => { describe('notifications repository', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); setProvider(new MemoryDatastoreProvider());
}); });
describe('getDevicesByUser', () => { describe('getDevicesByUser', () => {
it('returns devices for user', async () => { it('returns devices for user', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseDevice] }); await upsertDevice(baseDevice);
const result = await getDevicesByUser('user_1', 'lysnrai'); const result = await getDevicesByUser('user_1', 'lysnrai');
expect(result).toEqual([baseDevice]); expect(result).toHaveLength(1);
expect(result[0].id).toBe('dev_1');
}); });
it('returns empty array when no devices', async () => { it('returns empty array when no devices', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getDevicesByUser('user_1', 'lysnrai'); const result = await getDevicesByUser('user_1', 'lysnrai');
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@ -68,41 +58,29 @@ describe('notifications repository', () => {
describe('upsertDevice', () => { describe('upsertDevice', () => {
it('upserts and returns device', async () => { it('upserts and returns device', async () => {
mockUpsert.mockResolvedValue({ resource: baseDevice });
const result = await upsertDevice(baseDevice); const result = await upsertDevice(baseDevice);
expect(result).toEqual(baseDevice); expect(result.id).toBe('dev_1');
expect(result.platform).toBe('ios');
}); });
}); });
describe('removeDevice', () => { describe('removeDevice', () => {
it('returns true on successful delete', async () => { it('returns true on successful delete', async () => {
mockDelete.mockResolvedValue(undefined); await upsertDevice(baseDevice);
const result = await removeDevice('dev_1', 'user_1'); const result = await removeDevice('dev_1', 'user_1');
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('returns false on error', async () => {
mockDelete.mockRejectedValue(new Error('Not found'));
const result = await removeDevice('dev_1', 'user_1');
expect(result).toBe(false);
});
}); });
describe('getPrefs', () => { describe('getPrefs', () => {
it('returns prefs when found', async () => { it('returns prefs when found', async () => {
mockRead.mockResolvedValue({ resource: basePrefs }); await upsertPrefs(basePrefs);
const result = await getPrefs('user_1', 'lysnrai'); const result = await getPrefs('user_1', 'lysnrai');
expect(result).toEqual(basePrefs); expect(result).not.toBeNull();
expect(result!.pushEnabled).toBe(true);
}); });
it('returns null when not found', async () => { it('returns null when not found', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await getPrefs('user_1', 'lysnrai');
expect(result).toBeNull();
});
it('returns null when resource is undefined', async () => {
mockRead.mockResolvedValue({ resource: undefined });
const result = await getPrefs('user_1', 'lysnrai'); const result = await getPrefs('user_1', 'lysnrai');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -110,9 +88,9 @@ describe('notifications repository', () => {
describe('upsertPrefs', () => { describe('upsertPrefs', () => {
it('upserts and returns prefs', async () => { it('upserts and returns prefs', async () => {
mockUpsert.mockResolvedValue({ resource: basePrefs });
const result = await upsertPrefs(basePrefs); const result = await upsertPrefs(basePrefs);
expect(result).toEqual(basePrefs); expect(result.id).toBe('prefs_lysnrai_user_1');
expect(result.pushEnabled).toBe(true);
}); });
}); });
}); });

View File

@ -1,41 +1,33 @@
/** /**
* Notifications repository Cosmos DB CRUD for devices + prefs. * Notifications repository cloud-agnostic via @bytelyst/datastore.
*/ */
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { DeviceDoc, NotificationPrefsDoc } from './types.js'; import type { DeviceDoc, NotificationPrefsDoc } from './types.js';
function deviceContainer() { function devicesCollection() {
return getContainer('devices'); return getCollection<DeviceDoc>('devices', '/userId');
} }
function prefsContainer() { function prefsCollection() {
return getContainer('notification_prefs'); return getCollection<NotificationPrefsDoc>('notification_prefs', '/userId');
} }
// ── Devices ── // ── Devices ──
export async function getDevicesByUser(userId: string, productId: string): Promise<DeviceDoc[]> { export async function getDevicesByUser(userId: string, productId: string): Promise<DeviceDoc[]> {
const { resources } = await deviceContainer() return devicesCollection().findMany({
.items.query<DeviceDoc>({ filter: { productId, userId },
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId', });
parameters: [
{ name: '@productId', value: productId },
{ name: '@userId', value: userId },
],
})
.fetchAll();
return resources;
} }
export async function upsertDevice(doc: DeviceDoc): Promise<DeviceDoc> { export async function upsertDevice(doc: DeviceDoc): Promise<DeviceDoc> {
const { resource } = await deviceContainer().items.upsert<DeviceDoc>(doc); return devicesCollection().upsert(doc);
return resource!;
} }
export async function removeDevice(id: string, userId: string): Promise<boolean> { export async function removeDevice(id: string, userId: string): Promise<boolean> {
try { try {
await deviceContainer().item(id, userId).delete(); await devicesCollection().delete(id, userId);
return true; return true;
} catch { } catch {
return false; return false;
@ -50,14 +42,12 @@ export async function getPrefs(
): Promise<NotificationPrefsDoc | null> { ): Promise<NotificationPrefsDoc | null> {
const id = `prefs_${productId}_${userId}`; const id = `prefs_${productId}_${userId}`;
try { try {
const { resource } = await prefsContainer().item(id, userId).read<NotificationPrefsDoc>(); return await prefsCollection().findById(id, userId);
return resource ?? null;
} catch { } catch {
return null; return null;
} }
} }
export async function upsertPrefs(doc: NotificationPrefsDoc): Promise<NotificationPrefsDoc> { export async function upsertPrefs(doc: NotificationPrefsDoc): Promise<NotificationPrefsDoc> {
const { resource } = await prefsContainer().items.upsert<NotificationPrefsDoc>(doc); return prefsCollection().upsert(doc);
return resource!;
} }

View File

@ -1,24 +1,10 @@
/** /**
* Repository tests for subscriptions module mocked Cosmos DB. * Repository tests for subscriptions module in-memory datastore.
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
const mockFetchAll = vi.fn(); import { setProvider } from '../../lib/datastore.js';
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 { import {
getByUserId, getByUserId,
getByStripeCustomerIdAnyProduct, getByStripeCustomerIdAnyProduct,
@ -61,18 +47,18 @@ const basePayment: PaymentDoc = {
describe('subscriptions repository', () => { describe('subscriptions repository', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); setProvider(new MemoryDatastoreProvider());
}); });
describe('getByUserId', () => { describe('getByUserId', () => {
it('returns subscription when found', async () => { it('returns subscription when found', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseSub] }); await createSubscription(baseSub);
const result = await getByUserId('user_1', 'lysnrai'); const result = await getByUserId('user_1', 'lysnrai');
expect(result).toEqual(baseSub); expect(result).not.toBeNull();
expect(result!.id).toBe('sub_1');
}); });
it('returns null when not found', async () => { it('returns null when not found', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getByUserId('user_1', 'lysnrai'); const result = await getByUserId('user_1', 'lysnrai');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -80,13 +66,13 @@ describe('subscriptions repository', () => {
describe('getByStripeCustomerIdAnyProduct', () => { describe('getByStripeCustomerIdAnyProduct', () => {
it('returns subscription when found', async () => { it('returns subscription when found', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseSub] }); await createSubscription(baseSub);
const result = await getByStripeCustomerIdAnyProduct('cus_abc'); const result = await getByStripeCustomerIdAnyProduct('cus_abc');
expect(result).toEqual(baseSub); expect(result).not.toBeNull();
expect(result!.stripeCustomerId).toBe('cus_abc');
}); });
it('returns null when not found', async () => { it('returns null when not found', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getByStripeCustomerIdAnyProduct('cus_none'); const result = await getByStripeCustomerIdAnyProduct('cus_none');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -94,13 +80,13 @@ describe('subscriptions repository', () => {
describe('getByStripeCustomerId', () => { describe('getByStripeCustomerId', () => {
it('returns subscription when found', async () => { it('returns subscription when found', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseSub] }); await createSubscription(baseSub);
const result = await getByStripeCustomerId('cus_abc', 'lysnrai'); const result = await getByStripeCustomerId('cus_abc', 'lysnrai');
expect(result).toEqual(baseSub); expect(result).not.toBeNull();
expect(result!.stripeCustomerId).toBe('cus_abc');
}); });
it('returns null when not found', async () => { it('returns null when not found', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getByStripeCustomerId('cus_none', 'lysnrai'); const result = await getByStripeCustomerId('cus_none', 'lysnrai');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -108,29 +94,21 @@ describe('subscriptions repository', () => {
describe('createSubscription', () => { describe('createSubscription', () => {
it('creates and returns subscription', async () => { it('creates and returns subscription', async () => {
mockCreate.mockResolvedValue({ resource: baseSub });
const result = await createSubscription(baseSub); const result = await createSubscription(baseSub);
expect(result).toEqual(baseSub); expect(result.id).toBe('sub_1');
expect(result.plan).toBe('pro');
}); });
}); });
describe('updateSubscription', () => { describe('updateSubscription', () => {
it('merges updates and returns subscription', async () => { it('merges updates and returns subscription', async () => {
mockRead.mockResolvedValue({ resource: baseSub }); await createSubscription(baseSub);
const updated = { ...baseSub, plan: 'enterprise' as const };
mockReplace.mockResolvedValue({ resource: updated });
const result = await updateSubscription('sub_1', 'user_1', { plan: 'enterprise' }); const result = await updateSubscription('sub_1', 'user_1', { plan: 'enterprise' });
expect(result).toEqual(updated); expect(result).not.toBeNull();
expect(result!.plan).toBe('enterprise');
}); });
it('returns null when not found', async () => { it('returns null when not found', async () => {
mockRead.mockResolvedValue({ resource: undefined });
const result = await updateSubscription('sub_1', 'user_1', { plan: 'enterprise' });
expect(result).toBeNull();
});
it('returns null on error', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await updateSubscription('sub_1', 'user_1', { plan: 'enterprise' }); const result = await updateSubscription('sub_1', 'user_1', { plan: 'enterprise' });
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -138,13 +116,13 @@ describe('subscriptions repository', () => {
describe('getPaymentsByUser', () => { describe('getPaymentsByUser', () => {
it('returns payments for user', async () => { it('returns payments for user', async () => {
mockFetchAll.mockResolvedValue({ resources: [basePayment] }); await createPayment(basePayment);
const result = await getPaymentsByUser('user_1', 'lysnrai'); const result = await getPaymentsByUser('user_1', 'lysnrai');
expect(result).toEqual([basePayment]); expect(result).toHaveLength(1);
expect(result[0].id).toBe('pay_1');
}); });
it('returns empty array when no payments', async () => { it('returns empty array when no payments', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getPaymentsByUser('user_1', 'lysnrai'); const result = await getPaymentsByUser('user_1', 'lysnrai');
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@ -152,9 +130,9 @@ describe('subscriptions repository', () => {
describe('createPayment', () => { describe('createPayment', () => {
it('creates and returns payment', async () => { it('creates and returns payment', async () => {
mockCreate.mockResolvedValue({ resource: basePayment });
const result = await createPayment(basePayment); const result = await createPayment(basePayment);
expect(result).toEqual(basePayment); expect(result.id).toBe('pay_1');
expect(result.amount).toBe(999);
}); });
}); });
}); });

View File

@ -1,16 +1,16 @@
/** /**
* Subscriptions + payments repository Cosmos DB CRUD. * Subscriptions + payments repository cloud-agnostic via @bytelyst/datastore.
*/ */
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { SubscriptionDoc, PaymentDoc } from './types.js'; import type { SubscriptionDoc, PaymentDoc } from './types.js';
function subContainer() { function subsCollection() {
return getContainer('subscriptions'); return getCollection<SubscriptionDoc>('subscriptions', '/userId');
} }
function payContainer() { function paymentsCollection() {
return getContainer('payments'); return getCollection<PaymentDoc>('payments', '/userId');
} }
// ── Subscriptions ── // ── Subscriptions ──
@ -19,51 +19,33 @@ export async function getByUserId(
userId: string, userId: string,
productId: string productId: string
): Promise<SubscriptionDoc | null> { ): Promise<SubscriptionDoc | null> {
const { resources } = await subContainer() return subsCollection().findOne({
.items.query<SubscriptionDoc>({ filter: { productId, userId },
query: sort: { createdAt: -1 },
'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC', });
parameters: [
{ name: '@productId', value: productId },
{ name: '@userId', value: userId },
],
})
.fetchAll();
return resources[0] ?? null;
} }
export async function getByStripeCustomerIdAnyProduct( export async function getByStripeCustomerIdAnyProduct(
stripeCustomerId: string stripeCustomerId: string
): Promise<SubscriptionDoc | null> { ): Promise<SubscriptionDoc | null> {
const { resources } = await subContainer() return subsCollection().findOne({
.items.query<SubscriptionDoc>({ filter: { stripeCustomerId },
query: 'SELECT * FROM c WHERE c.stripeCustomerId = @cid ORDER BY c.createdAt DESC', sort: { createdAt: -1 },
parameters: [{ name: '@cid', value: stripeCustomerId }], });
})
.fetchAll();
return resources[0] ?? null;
} }
export async function getByStripeCustomerId( export async function getByStripeCustomerId(
stripeCustomerId: string, stripeCustomerId: string,
productId: string productId: string
): Promise<SubscriptionDoc | null> { ): Promise<SubscriptionDoc | null> {
const { resources } = await subContainer() return subsCollection().findOne({
.items.query<SubscriptionDoc>({ filter: { productId, stripeCustomerId },
query: sort: { createdAt: -1 },
'SELECT * FROM c WHERE c.productId = @productId AND c.stripeCustomerId = @cid ORDER BY c.createdAt DESC', });
parameters: [
{ name: '@productId', value: productId },
{ name: '@cid', value: stripeCustomerId },
],
})
.fetchAll();
return resources[0] ?? null;
} }
export async function createSubscription(doc: SubscriptionDoc): Promise<SubscriptionDoc> { export async function createSubscription(doc: SubscriptionDoc): Promise<SubscriptionDoc> {
const { resource } = await subContainer().items.create(doc); return subsCollection().create(doc);
return resource as SubscriptionDoc;
} }
export async function updateSubscription( export async function updateSubscription(
@ -72,11 +54,10 @@ export async function updateSubscription(
updates: Partial<SubscriptionDoc> updates: Partial<SubscriptionDoc>
): Promise<SubscriptionDoc | null> { ): Promise<SubscriptionDoc | null> {
try { try {
const { resource: existing } = await subContainer().item(id, userId).read<SubscriptionDoc>(); return await subsCollection().update(id, userId, {
if (!existing) return null; ...updates,
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; updatedAt: new Date().toISOString(),
const { resource } = await subContainer().item(id, userId).replace(merged); });
return resource as SubscriptionDoc;
} catch { } catch {
return null; return null;
} }
@ -89,21 +70,13 @@ export async function getPaymentsByUser(
productId: string, productId: string,
limit = 50 limit = 50
): Promise<PaymentDoc[]> { ): Promise<PaymentDoc[]> {
const { resources } = await payContainer() return paymentsCollection().findMany({
.items.query<PaymentDoc>({ filter: { productId, userId },
query: sort: { createdAt: -1 },
'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit', limit,
parameters: [ });
{ name: '@productId', value: productId },
{ name: '@userId', value: userId },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources;
} }
export async function createPayment(doc: PaymentDoc): Promise<PaymentDoc> { export async function createPayment(doc: PaymentDoc): Promise<PaymentDoc> {
const { resource } = await payContainer().items.create(doc); return paymentsCollection().create(doc);
return resource as PaymentDoc;
} }

View File

@ -1,8 +1,9 @@
/** /**
* Telemetry repository Cosmos DB CRUD for events, policies, and clusters. * Telemetry repository cloud-agnostic via @bytelyst/datastore.
*/ */
import { getContainer } from '../../lib/cosmos.js'; import type { FilterMap } from '@bytelyst/datastore';
import { getCollection } from '../../lib/datastore.js';
import type { import type {
TelemetryEventDoc, TelemetryEventDoc,
TelemetryCollectionPolicyDoc, TelemetryCollectionPolicyDoc,
@ -10,31 +11,29 @@ import type {
TelemetryQueryInput, TelemetryQueryInput,
} from './types.js'; } from './types.js';
// ─── Container accessors ──────────────────────────────────────────── // ─── Collection accessors ───────────────────────────────────────────
function eventsContainer() { function eventsCollection() {
return getContainer('telemetry_events'); return getCollection<TelemetryEventDoc>('telemetry_events', '/pk');
} }
function policiesContainer() { function policiesCollection() {
return getContainer('telemetry_collection_policies'); return getCollection<TelemetryCollectionPolicyDoc>('telemetry_collection_policies', '/productId');
} }
function clustersContainer() { function clustersCollection() {
return getContainer('telemetry_error_clusters'); return getCollection<TelemetryErrorCluster>('telemetry_error_clusters', '/pk');
} }
// ─── Events ───────────────────────────────────────────────────────── // ─── Events ─────────────────────────────────────────────────────────
export async function upsertEvent(doc: TelemetryEventDoc): Promise<void> { export async function upsertEvent(doc: TelemetryEventDoc): Promise<void> {
await eventsContainer().items.upsert(doc); await eventsCollection().upsert(doc);
} }
export async function upsertEventsBatch(docs: TelemetryEventDoc[]): Promise<void> { export async function upsertEventsBatch(docs: TelemetryEventDoc[]): Promise<void> {
// Cosmos DB doesn't have native batch across partitions.
// We upsert individually; for v1 this is acceptable.
// TODO: Group by pk and use bulk operations for same-partition batches. // TODO: Group by pk and use bulk operations for same-partition batches.
const promises = docs.map(doc => eventsContainer().items.upsert(doc)); const promises = docs.map(doc => eventsCollection().upsert(doc));
await Promise.all(promises); await Promise.all(promises);
} }
@ -42,75 +41,31 @@ export async function queryEvents(
productId: string, productId: string,
input: TelemetryQueryInput input: TelemetryQueryInput
): Promise<{ events: TelemetryEventDoc[]; continuationToken?: string }> { ): Promise<{ events: TelemetryEventDoc[]; continuationToken?: string }> {
const conditions: string[] = ['c.productId = @productId']; const filter: FilterMap = { productId };
const parameters: Array<{ name: string; value: string | number | boolean }> = [
{ name: '@productId', value: productId },
];
if (input.userId) { if (input.userId) filter.userId = input.userId;
conditions.push('c.userId = @userId'); if (input.anonymousInstallId) filter.anonymousInstallId = input.anonymousInstallId;
parameters.push({ name: '@userId', value: input.userId }); if (input.platform) filter.platform = input.platform;
} if (input.channel) filter.channel = input.channel;
if (input.anonymousInstallId) { if (input.osFamily) filter.osFamily = input.osFamily;
conditions.push('c.anonymousInstallId = @anonymousInstallId'); if (input.appVersion) filter.appVersion = input.appVersion;
parameters.push({ name: '@anonymousInstallId', value: input.anonymousInstallId }); if (input.buildNumber) filter.buildNumber = input.buildNumber;
} if (input.module) filter.module = input.module;
if (input.platform) { if (input.eventName) filter.eventName = input.eventName;
conditions.push('c.platform = @platform'); if (input.eventType) filter.eventType = input.eventType;
parameters.push({ name: '@platform', value: input.platform }); if (input.from)
} filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $gte: input.from };
if (input.channel) { if (input.to) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $lte: input.to };
conditions.push('c.channel = @channel');
parameters.push({ name: '@channel', value: input.channel });
}
if (input.osFamily) {
conditions.push('c.osFamily = @osFamily');
parameters.push({ name: '@osFamily', value: input.osFamily });
}
if (input.appVersion) {
conditions.push('c.appVersion = @appVersion');
parameters.push({ name: '@appVersion', value: input.appVersion });
}
if (input.buildNumber) {
conditions.push('c.buildNumber = @buildNumber');
parameters.push({ name: '@buildNumber', value: input.buildNumber });
}
if (input.module) {
conditions.push('c.module = @module');
parameters.push({ name: '@module', value: input.module });
}
if (input.eventName) {
conditions.push('c.eventName = @eventName');
parameters.push({ name: '@eventName', value: input.eventName });
}
if (input.eventType) {
conditions.push('c.eventType = @eventType');
parameters.push({ name: '@eventType', value: input.eventType });
}
if (input.from) {
conditions.push('c.occurredAt >= @from');
parameters.push({ name: '@from', value: input.from });
}
if (input.to) {
conditions.push('c.occurredAt <= @to');
parameters.push({ name: '@to', value: input.to });
}
const query = `SELECT * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.occurredAt DESC`; // Note: continuationToken is not supported in the abstraction layer.
// For v1, we fetch with limit only.
const events = await eventsCollection().findMany({
filter,
sort: { occurredAt: -1 },
limit: input.limit,
});
const iterator = eventsContainer().items.query<TelemetryEventDoc>( return { events, continuationToken: undefined };
{ query, parameters },
{
maxItemCount: input.limit,
continuationToken: input.continuationToken || undefined,
}
);
const { resources, continuationToken } = await iterator.fetchNext();
return {
events: resources,
continuationToken: continuationToken || undefined,
};
} }
export async function queryGeoDistribution( export async function queryGeoDistribution(
@ -118,46 +73,38 @@ export async function queryGeoDistribution(
from?: string, from?: string,
to?: string to?: string
): Promise<Array<{ countryCode: string; count: number }>> { ): Promise<Array<{ countryCode: string; count: number }>> {
const conditions: string[] = [ const filter: FilterMap = {
'c.productId = @productId', productId,
'IS_DEFINED(c.countryCode)', countryCode: { $exists: true },
'c.countryCode != null', };
]; if (from) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $gte: from };
const parameters: Array<{ name: string; value: string }> = [ if (to) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $lte: to };
{ name: '@productId', value: productId },
]; // Fetch docs and aggregate in-memory
if (from) { const docs = await eventsCollection().findMany({ filter });
conditions.push('c.occurredAt >= @from');
parameters.push({ name: '@from', value: from }); const counts: Record<string, number> = {};
} for (const doc of docs) {
if (to) { if (doc.countryCode) {
conditions.push('c.occurredAt <= @to'); counts[doc.countryCode] = (counts[doc.countryCode] ?? 0) + 1;
parameters.push({ name: '@to', value: to }); }
} }
const query = `SELECT c.countryCode, COUNT(1) AS count FROM c WHERE ${conditions.join(' AND ')} GROUP BY c.countryCode`; return Object.entries(counts)
const { resources } = await eventsContainer() .map(([countryCode, count]) => ({ countryCode, count }))
.items.query<{ countryCode: string; count: number }>({ query, parameters }) .sort((a, b) => b.count - a.count);
.fetchAll();
return resources.sort((a, b) => b.count - a.count);
} }
export async function deleteEventsByUserId(productId: string, userId: string): Promise<number> { export async function deleteEventsByUserId(productId: string, userId: string): Promise<number> {
// Find all events for this user, then delete them // Find all events for this user, then delete them
const { resources } = await eventsContainer() const docs = await eventsCollection().findMany({
.items.query<{ id: string; pk: string }>({ filter: { productId, userId },
query: 'SELECT c.id, c.pk FROM c WHERE c.productId = @productId AND c.userId = @userId', });
parameters: [
{ name: '@productId', value: productId },
{ name: '@userId', value: userId },
],
})
.fetchAll();
let deleted = 0; let deleted = 0;
for (const doc of resources) { for (const doc of docs) {
try { try {
await eventsContainer().item(doc.id, doc.pk).delete(); await eventsCollection().delete(doc.id, doc.pk);
deleted++; deleted++;
} catch { } catch {
// Skip docs that fail to delete (already deleted, etc.) // Skip docs that fail to delete (already deleted, etc.)
@ -169,13 +116,10 @@ export async function deleteEventsByUserId(productId: string, userId: string): P
// ─── Policies ─────────────────────────────────────────────────────── // ─── Policies ───────────────────────────────────────────────────────
export async function listPolicies(productId: string): Promise<TelemetryCollectionPolicyDoc[]> { export async function listPolicies(productId: string): Promise<TelemetryCollectionPolicyDoc[]> {
const { resources } = await policiesContainer() return policiesCollection().findMany({
.items.query<TelemetryCollectionPolicyDoc>({ filter: { productId },
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.priority DESC', sort: { priority: -1 },
parameters: [{ name: '@productId', value: productId }], });
})
.fetchAll();
return resources;
} }
export async function getPolicy( export async function getPolicy(
@ -183,10 +127,7 @@ export async function getPolicy(
productId: string productId: string
): Promise<TelemetryCollectionPolicyDoc | null> { ): Promise<TelemetryCollectionPolicyDoc | null> {
try { try {
const { resource } = await policiesContainer() return await policiesCollection().findById(id, productId);
.item(id, productId)
.read<TelemetryCollectionPolicyDoc>();
return resource ?? null;
} catch { } catch {
return null; return null;
} }
@ -195,8 +136,7 @@ export async function getPolicy(
export async function createPolicy( export async function createPolicy(
doc: TelemetryCollectionPolicyDoc doc: TelemetryCollectionPolicyDoc
): Promise<TelemetryCollectionPolicyDoc> { ): Promise<TelemetryCollectionPolicyDoc> {
const { resource } = await policiesContainer().items.create(doc); return policiesCollection().create(doc);
return resource as TelemetryCollectionPolicyDoc;
} }
export async function updatePolicy( export async function updatePolicy(
@ -205,13 +145,10 @@ export async function updatePolicy(
updates: Partial<TelemetryCollectionPolicyDoc> updates: Partial<TelemetryCollectionPolicyDoc>
): Promise<TelemetryCollectionPolicyDoc | null> { ): Promise<TelemetryCollectionPolicyDoc | null> {
try { try {
const { resource: existing } = await policiesContainer() return await policiesCollection().update(id, productId, {
.item(id, productId) ...updates,
.read<TelemetryCollectionPolicyDoc>(); updatedAt: new Date().toISOString(),
if (!existing) return null; });
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await policiesContainer().item(id, productId).replace(merged);
return resource as TelemetryCollectionPolicyDoc;
} catch { } catch {
return null; return null;
} }
@ -219,7 +156,7 @@ export async function updatePolicy(
export async function deletePolicy(id: string, productId: string): Promise<boolean> { export async function deletePolicy(id: string, productId: string): Promise<boolean> {
try { try {
await policiesContainer().item(id, productId).delete(); await policiesCollection().delete(id, productId);
return true; return true;
} catch { } catch {
return false; return false;
@ -238,45 +175,29 @@ export async function listClusters(
limit?: number; limit?: number;
} }
): Promise<TelemetryErrorCluster[]> { ): Promise<TelemetryErrorCluster[]> {
const conditions: string[] = ['c.productId = @productId']; const filter: FilterMap = { productId };
const parameters: Array<{ name: string; value: string | number | boolean }> = [
{ name: '@productId', value: productId },
];
if (filters?.platform) { if (filters?.platform) filter.platform = filters.platform;
conditions.push('c.platform = @platform'); if (filters?.module) filter.module = filters.module;
parameters.push({ name: '@platform', value: filters.platform }); if (filters?.from)
} filter.lastSeenAt = { ...((filter.lastSeenAt as object) ?? {}), $gte: filters.from };
if (filters?.module) { if (filters?.to)
conditions.push('c.module = @module'); filter.lastSeenAt = { ...((filter.lastSeenAt as object) ?? {}), $lte: filters.to };
parameters.push({ name: '@module', value: filters.module });
}
if (filters?.from) {
conditions.push('c.lastSeenAt >= @from');
parameters.push({ name: '@from', value: filters.from });
}
if (filters?.to) {
conditions.push('c.lastSeenAt <= @to');
parameters.push({ name: '@to', value: filters.to });
}
const limit = filters?.limit ?? 50; return clustersCollection().findMany({
const query = `SELECT TOP ${limit} * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.totalCount DESC`; filter,
sort: { totalCount: -1 },
const { resources } = await clustersContainer() limit: filters?.limit ?? 50,
.items.query<TelemetryErrorCluster>({ query, parameters }) });
.fetchAll();
return resources;
} }
export async function upsertCluster(cluster: TelemetryErrorCluster): Promise<void> { export async function upsertCluster(cluster: TelemetryErrorCluster): Promise<void> {
await clustersContainer().items.upsert(cluster); await clustersCollection().upsert(cluster);
} }
export async function getCluster(id: string, pk: string): Promise<TelemetryErrorCluster | null> { export async function getCluster(id: string, pk: string): Promise<TelemetryErrorCluster | null> {
try { try {
const { resource } = await clustersContainer().item(id, pk).read<TelemetryErrorCluster>(); return await clustersCollection().findById(id, pk);
return resource ?? null;
} catch { } catch {
return null; return null;
} }
@ -288,13 +209,7 @@ export async function updateCluster(
updates: Partial<TelemetryErrorCluster> updates: Partial<TelemetryErrorCluster>
): Promise<TelemetryErrorCluster | null> { ): Promise<TelemetryErrorCluster | null> {
try { try {
const { resource: existing } = await clustersContainer() return await clustersCollection().update(id, pk, updates);
.item(id, pk)
.read<TelemetryErrorCluster>();
if (!existing) return null;
const merged = { ...existing, ...updates };
const { resource } = await clustersContainer().item(id, pk).replace(merged);
return resource as unknown as TelemetryErrorCluster;
} catch { } catch {
return null; return null;
} }

View File

@ -1,30 +1,11 @@
/** /**
* Repository tests for tokens module mocked Cosmos DB. * Repository tests for tokens module in-memory datastore.
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
const mockFetchAll = vi.fn(); import { setProvider } from '../../lib/datastore.js';
const mockCreate = vi.fn(); import { list, listByUser, getById, create, revoke, remove, countActive } from './repository.js';
const mockRead = vi.fn();
const mockReplace = vi.fn();
const mockDelete = vi.fn();
vi.mock('../../lib/cosmos.js', () => ({
getContainer: vi.fn(() => ({
items: {
query: () => ({ fetchAll: mockFetchAll }),
create: mockCreate,
},
item: () => ({ read: mockRead, replace: mockReplace, delete: mockDelete }),
})),
}));
vi.mock('bcryptjs', () => ({
default: { hash: vi.fn().mockResolvedValue('hashed_token') },
}));
import { list, listByUser, getById, create, revoke, remove, countActive, hashToken } from './repository.js';
import type { ApiTokenDoc } from './types.js'; import type { ApiTokenDoc } from './types.js';
const baseToken: ApiTokenDoc = { const baseToken: ApiTokenDoc = {
@ -44,19 +25,18 @@ const baseToken: ApiTokenDoc = {
describe('tokens repository', () => { describe('tokens repository', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); setProvider(new MemoryDatastoreProvider());
}); });
describe('list', () => { describe('list', () => {
it('returns tokens without hashes', async () => { it('returns tokens without hashes', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseToken] }); await create(baseToken);
const result = await list('lysnrai'); const result = await list('lysnrai');
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0]).not.toHaveProperty('tokenHash'); expect(result[0]).not.toHaveProperty('tokenHash');
}); });
it('returns empty array when no tokens', async () => { it('returns empty array when no tokens', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list('lysnrai'); const result = await list('lysnrai');
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@ -64,7 +44,7 @@ describe('tokens repository', () => {
describe('listByUser', () => { describe('listByUser', () => {
it('returns tokens for user without hashes', async () => { it('returns tokens for user without hashes', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseToken] }); await create(baseToken);
const result = await listByUser('user_1', 'lysnrai'); const result = await listByUser('user_1', 'lysnrai');
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0]).not.toHaveProperty('tokenHash'); expect(result[0]).not.toHaveProperty('tokenHash');
@ -73,19 +53,13 @@ describe('tokens repository', () => {
describe('getById', () => { describe('getById', () => {
it('returns token when found', async () => { it('returns token when found', async () => {
mockRead.mockResolvedValue({ resource: baseToken }); await create(baseToken);
const result = await getById('tok_1', 'user_1'); const result = await getById('tok_1', 'user_1');
expect(result).toEqual(baseToken); expect(result).not.toBeNull();
expect(result!.id).toBe('tok_1');
}); });
it('returns null when not found', async () => { it('returns null when not found', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await getById('tok_1', 'user_1');
expect(result).toBeNull();
});
it('returns null when resource is undefined', async () => {
mockRead.mockResolvedValue({ resource: undefined });
const result = await getById('tok_1', 'user_1'); const result = await getById('tok_1', 'user_1');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -93,7 +67,6 @@ describe('tokens repository', () => {
describe('create', () => { describe('create', () => {
it('creates and returns token without hash', async () => { it('creates and returns token without hash', async () => {
mockCreate.mockResolvedValue({ resource: baseToken });
const result = await create(baseToken); const result = await create(baseToken);
expect(result).not.toHaveProperty('tokenHash'); expect(result).not.toHaveProperty('tokenHash');
expect(result.id).toBe('tok_1'); expect(result.id).toBe('tok_1');
@ -102,14 +75,14 @@ describe('tokens repository', () => {
describe('revoke', () => { describe('revoke', () => {
it('revokes an existing token', async () => { it('revokes an existing token', async () => {
mockRead.mockResolvedValue({ resource: baseToken }); await create(baseToken);
mockReplace.mockResolvedValue({ resource: { ...baseToken, status: 'revoked' } });
const result = await revoke('tok_1', 'user_1'); const result = await revoke('tok_1', 'user_1');
expect(result).toBe(true); expect(result).toBe(true);
const updated = await getById('tok_1', 'user_1');
expect(updated!.status).toBe('revoked');
}); });
it('returns false when token not found', async () => { it('returns false when token not found', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await revoke('tok_1', 'user_1'); const result = await revoke('tok_1', 'user_1');
expect(result).toBe(false); expect(result).toBe(false);
}); });
@ -117,36 +90,22 @@ describe('tokens repository', () => {
describe('remove', () => { describe('remove', () => {
it('deletes and returns true', async () => { it('deletes and returns true', async () => {
mockDelete.mockResolvedValue(undefined); await create(baseToken);
const result = await remove('tok_1', 'user_1'); const result = await remove('tok_1', 'user_1');
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('returns false on error', async () => {
mockDelete.mockRejectedValue(new Error('Not found'));
const result = await remove('tok_1', 'user_1');
expect(result).toBe(false);
});
}); });
describe('countActive', () => { describe('countActive', () => {
it('returns count', async () => { it('returns count', async () => {
mockFetchAll.mockResolvedValue({ resources: [5] }); await create(baseToken);
const result = await countActive('lysnrai'); const result = await countActive('lysnrai');
expect(result).toBe(5); expect(result).toBe(1);
}); });
it('returns 0 when no results', async () => { it('returns 0 when no results', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await countActive('lysnrai'); const result = await countActive('lysnrai');
expect(result).toBe(0); expect(result).toBe(0);
}); });
}); });
describe('hashToken', () => {
it('hashes a token string', async () => {
const result = await hashToken('raw_token');
expect(result).toBe('hashed_token');
});
});
}); });

View File

@ -1,13 +1,13 @@
/** /**
* API token repository Cosmos DB. * API token repository cloud-agnostic via @bytelyst/datastore.
*/ */
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { ApiTokenDoc, ApiTokenResponse } from './types.js'; import type { ApiTokenDoc, ApiTokenResponse } from './types.js';
function container() { function collection() {
return getContainer('api_tokens'); return getCollection<ApiTokenDoc>('api_tokens', '/userId');
} }
function stripHash(doc: ApiTokenDoc): ApiTokenResponse { function stripHash(doc: ApiTokenDoc): ApiTokenResponse {
@ -17,63 +17,46 @@ function stripHash(doc: ApiTokenDoc): ApiTokenResponse {
} }
export async function list(productId: string, limit = 100): Promise<ApiTokenResponse[]> { export async function list(productId: string, limit = 100): Promise<ApiTokenResponse[]> {
const { resources } = await container() const results = await collection().findMany({
.items.query<ApiTokenDoc>({ filter: { productId, status: { $ne: 'expired' } },
query: sort: { createdAt: -1 },
"SELECT * FROM c WHERE c.productId = @productId AND c.status != 'expired' ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit", limit,
parameters: [ });
{ name: '@productId', value: productId }, return results.map(stripHash);
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources.map(stripHash);
} }
export async function listByUser(userId: string, productId: string): Promise<ApiTokenResponse[]> { export async function listByUser(userId: string, productId: string): Promise<ApiTokenResponse[]> {
const { resources } = await container() const results = await collection().findMany({
.items.query<ApiTokenDoc>({ filter: { productId, userId },
query: sort: { createdAt: -1 },
'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC', });
parameters: [ return results.map(stripHash);
{ name: '@productId', value: productId },
{ name: '@userId', value: userId },
],
})
.fetchAll();
return resources.map(stripHash);
} }
export async function getById(id: string, userId: string): Promise<ApiTokenDoc | null> { export async function getById(id: string, userId: string): Promise<ApiTokenDoc | null> {
try { try {
const { resource } = await container().item(id, userId).read<ApiTokenDoc>(); return await collection().findById(id, userId);
return resource ?? null;
} catch { } catch {
return null; return null;
} }
} }
export async function create(doc: ApiTokenDoc): Promise<ApiTokenResponse> { export async function create(doc: ApiTokenDoc): Promise<ApiTokenResponse> {
const { resource } = await container().items.create<ApiTokenDoc>(doc); const created = await collection().create(doc);
return stripHash(resource!); return stripHash(created);
} }
export async function revoke(id: string, userId: string): Promise<boolean> { export async function revoke(id: string, userId: string): Promise<boolean> {
const existing = await getById(id, userId); const existing = await getById(id, userId);
if (!existing) return false; if (!existing) return false;
await container() await collection().update(id, userId, { status: 'revoked' } as Partial<ApiTokenDoc>);
.item(id, userId)
.replace<ApiTokenDoc>({
...existing,
status: 'revoked',
});
return true; return true;
} }
export async function remove(id: string, userId: string): Promise<boolean> { export async function remove(id: string, userId: string): Promise<boolean> {
try { try {
await container().item(id, userId).delete(); await collection().delete(id, userId);
return true; return true;
} catch { } catch {
return false; return false;
@ -81,13 +64,7 @@ export async function remove(id: string, userId: string): Promise<boolean> {
} }
export async function countActive(productId: string): Promise<number> { export async function countActive(productId: string): Promise<number> {
const { resources } = await container() return collection().count({ productId, status: 'active' });
.items.query<number>({
query: "SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status = 'active'",
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
return resources[0] ?? 0;
} }
export async function hashToken(raw: string): Promise<string> { export async function hashToken(raw: string): Promise<string> {

View File

@ -1,59 +1,43 @@
/** /**
* Repository tests for usage module mocked Cosmos DB. * Repository tests for usage module in-memory datastore.
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
const mockFetchAll = vi.fn(); import { setProvider } from '../../lib/datastore.js';
const mockUpsert = vi.fn();
const mockRead = vi.fn();
vi.mock('../../lib/cosmos.js', () => ({
getContainer: vi.fn(() => ({
items: {
query: () => ({ fetchAll: mockFetchAll }),
upsert: mockUpsert,
},
item: () => ({ read: mockRead }),
})),
}));
import { getByDate, list, upsert, getMonthlyUsage } from './repository.js'; import { getByDate, list, upsert, getMonthlyUsage } from './repository.js';
import type { UsageDoc } from './types.js'; import type { UsageDoc } from './types.js';
// Use today's date so getMonthlyUsage picks it up
const todayStr = new Date().toISOString().slice(0, 10);
const baseUsage: UsageDoc = { const baseUsage: UsageDoc = {
id: 'usg_2026-02-16_user_1', id: `usg_${todayStr}_user_1`,
productId: 'lysnrai', productId: 'lysnrai',
userId: 'user_1', userId: 'user_1',
date: '2026-02-16', date: todayStr,
dictations: 5, dictations: 5,
words: 250, words: 250,
durationMs: 30000, durationMs: 30000,
tokensUsed: 1200, tokensUsed: 1200,
costUsd: 0.01, costUsd: 0.01,
createdAt: '2026-02-16T00:00:00Z', createdAt: `${todayStr}T00:00:00Z`,
}; };
describe('usage repository', () => { describe('usage repository', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); setProvider(new MemoryDatastoreProvider());
}); });
describe('getByDate', () => { describe('getByDate', () => {
it('returns usage when found', async () => { it('returns usage when found', async () => {
mockRead.mockResolvedValue({ resource: baseUsage }); await upsert(baseUsage);
const result = await getByDate('user_1', '2026-02-16'); const result = await getByDate('user_1', todayStr);
expect(result).toEqual(baseUsage); expect(result).not.toBeNull();
expect(result!.dictations).toBe(5);
}); });
it('returns null when not found', async () => { it('returns null when not found', async () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await getByDate('user_1', '2026-02-16');
expect(result).toBeNull();
});
it('returns null when resource is undefined', async () => {
mockRead.mockResolvedValue({ resource: undefined });
const result = await getByDate('user_1', '2026-02-16'); const result = await getByDate('user_1', '2026-02-16');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -61,19 +45,18 @@ describe('usage repository', () => {
describe('list', () => { describe('list', () => {
it('returns usage records', async () => { it('returns usage records', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseUsage] }); await upsert(baseUsage);
const result = await list({ userId: 'user_1', productId: 'lysnrai' }); const result = await list({ userId: 'user_1', productId: 'lysnrai' });
expect(result).toEqual([baseUsage]); expect(result).toHaveLength(1);
expect(result[0].userId).toBe('user_1');
}); });
it('returns empty array when no records', async () => { it('returns empty array when no records', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list({}); const result = await list({});
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
it('uses default values for days and limit', async () => { it('uses default values for days and limit', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list(); const result = await list();
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@ -81,30 +64,26 @@ describe('usage repository', () => {
describe('upsert', () => { describe('upsert', () => {
it('upserts and returns usage', async () => { it('upserts and returns usage', async () => {
mockUpsert.mockResolvedValue({ resource: baseUsage });
const result = await upsert(baseUsage); const result = await upsert(baseUsage);
expect(result).toEqual(baseUsage); expect(result.id).toBe(baseUsage.id);
expect(result.words).toBe(250);
}); });
}); });
describe('getMonthlyUsage', () => { describe('getMonthlyUsage', () => {
it('returns aggregated monthly usage', async () => { it('returns aggregated monthly usage', async () => {
mockFetchAll.mockResolvedValue({ await upsert(baseUsage);
resources: [{ totalTokens: 5000, totalWords: 2500, totalDictations: 20 }],
});
const result = await getMonthlyUsage('user_1', 'lysnrai'); const result = await getMonthlyUsage('user_1', 'lysnrai');
expect(result).toEqual({ tokens: 5000, words: 2500, dictations: 20 }); expect(result).toEqual({ tokens: 1200, words: 250, dictations: 5 });
}); });
it('returns zeros when no data', async () => { it('returns zeros when no data', async () => {
mockFetchAll.mockResolvedValue({ resources: [undefined] });
const result = await getMonthlyUsage('user_1', 'lysnrai'); const result = await getMonthlyUsage('user_1', 'lysnrai');
expect(result).toEqual({ tokens: 0, words: 0, dictations: 0 }); expect(result).toEqual({ tokens: 0, words: 0, dictations: 0 });
}); });
it('returns zeros when empty resources', async () => { it('returns zeros when empty resources', async () => {
mockFetchAll.mockResolvedValue({ resources: [] }); const result = await getMonthlyUsage('nonexistent', 'lysnrai');
const result = await getMonthlyUsage('user_1', 'lysnrai');
expect(result).toEqual({ tokens: 0, words: 0, dictations: 0 }); expect(result).toEqual({ tokens: 0, words: 0, dictations: 0 });
}); });
}); });

View File

@ -1,19 +1,18 @@
/** /**
* Usage repository Cosmos DB CRUD + aggregation. * Usage repository cloud-agnostic via @bytelyst/datastore.
*/ */
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { UsageDoc, MonthlyUsage } from './types.js'; import type { UsageDoc, MonthlyUsage } from './types.js';
function container() { function collection() {
return getContainer('usage_daily'); return getCollection<UsageDoc>('usage_daily', '/userId');
} }
export async function getByDate(userId: string, date: string): Promise<UsageDoc | null> { export async function getByDate(userId: string, date: string): Promise<UsageDoc | null> {
const id = `usg_${date}_${userId}`; const id = `usg_${date}_${userId}`;
try { try {
const { resource } = await container().item(id, userId).read<UsageDoc>(); return await collection().findById(id, userId);
return resource ?? null;
} catch { } catch {
return null; return null;
} }
@ -25,59 +24,43 @@ export async function list(
const { userId, days = 30, limit = 100, productId } = options; const { userId, days = 30, limit = 100, productId } = options;
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
let queryText = 'SELECT * FROM c WHERE c.date >= @since'; // Fetch all matching docs and filter in-memory for $gte on date
const parameters: { name: string; value: string | number }[] = [{ name: '@since', value: since }]; const filter: Record<string, unknown> = { date: { $gte: since } };
if (productId) filter.productId = productId;
if (userId) filter.userId = userId;
if (productId) { return collection().findMany({
queryText += ' AND c.productId = @productId'; filter: filter as import('@bytelyst/datastore').FilterMap,
parameters.push({ name: '@productId', value: productId }); sort: { date: -1 },
} limit,
});
if (userId) {
queryText += ' AND c.userId = @userId';
parameters.push({ name: '@userId', value: userId });
}
queryText += ' ORDER BY c.date DESC OFFSET 0 LIMIT @limit';
parameters.push({ name: '@limit', value: limit });
const { resources } = await container()
.items.query<UsageDoc>({ query: queryText, parameters })
.fetchAll();
return resources;
} }
export async function upsert(doc: UsageDoc): Promise<UsageDoc> { export async function upsert(doc: UsageDoc): Promise<UsageDoc> {
const { resource } = await container().items.upsert<UsageDoc>(doc); return collection().upsert(doc);
return resource!;
} }
export async function getMonthlyUsage(userId: string, productId: string): Promise<MonthlyUsage> { export async function getMonthlyUsage(userId: string, productId: string): Promise<MonthlyUsage> {
const now = new Date(); const now = new Date();
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`; const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
const query = // Fetch docs for this month and aggregate in-memory
'SELECT VALUE {' + const docs = await collection().findMany({
' totalTokens: SUM(c.tokensUsed), ' + filter: {
' totalWords: SUM(c.words), ' + productId,
' totalDictations: SUM(c.dictations)' + userId,
'} FROM c WHERE c.productId = @productId AND c.userId = @uid AND c.date >= @since'; date: { $gte: monthStart },
} as import('@bytelyst/datastore').FilterMap,
});
const { resources } = await container() let tokens = 0;
.items.query<{ totalTokens: number; totalWords: number; totalDictations: number }>({ let words = 0;
query, let dictations = 0;
parameters: [ for (const doc of docs) {
{ name: '@productId', value: productId }, tokens += doc.tokensUsed ?? 0;
{ name: '@uid', value: userId }, words += doc.words ?? 0;
{ name: '@since', value: monthStart }, dictations += doc.dictations ?? 0;
], }
})
.fetchAll();
const row = resources[0]; return { tokens, words, dictations };
return {
tokens: row?.totalTokens ?? 0,
words: row?.totalWords ?? 0,
dictations: row?.totalDictations ?? 0,
};
} }

View File

@ -1,18 +1,16 @@
/** /**
* Waitlist repository Cosmos DB CRUD operations. * Waitlist repository cloud-agnostic via @bytelyst/datastore.
* *
* Container: `waitlist`, partition key: `/email` * Collection: `waitlist`, partition key: `/email`
* Cross-partition queries used for admin list/count/stats (acceptable for low-frequency reads). * Cross-partition queries used for admin list/count/stats (acceptable for low-frequency reads).
*/ */
import type { SqlParameter } from '@azure/cosmos'; import type { FilterMap } from '@bytelyst/datastore';
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { WaitlistEntryDoc, WaitlistStatus, WaitlistSource } from './types.js'; import type { WaitlistEntryDoc, WaitlistStatus, WaitlistSource } from './types.js';
const CONTAINER = 'waitlist'; function collection() {
return getCollection<WaitlistEntryDoc>('waitlist', '/email');
function container() {
return getContainer(CONTAINER);
} }
// ── Normalize email for case-insensitive dedup ── // ── Normalize email for case-insensitive dedup ──
@ -24,47 +22,29 @@ export function normalizeEmail(email: string): string {
// ── Create ── // ── Create ──
export async function create(doc: WaitlistEntryDoc): Promise<WaitlistEntryDoc> { export async function create(doc: WaitlistEntryDoc): Promise<WaitlistEntryDoc> {
const { resource } = await container().items.create(doc); return collection().create(doc);
return resource as WaitlistEntryDoc;
} }
// ── Read (single) ── // ── Read (single) ──
export async function getById(id: string): Promise<WaitlistEntryDoc | null> { export async function getById(id: string): Promise<WaitlistEntryDoc | null> {
// id-based lookup requires cross-partition query (partition is /email) // Cross-partition lookup by id
const { resources } = await container() return collection().findOne({ filter: { id } });
.items.query<WaitlistEntryDoc>({
query: 'SELECT * FROM c WHERE c.id = @id',
parameters: [{ name: '@id', value: id }],
})
.fetchAll();
return resources[0] ?? null;
} }
export async function getByEmail( export async function getByEmail(
emailNormalized: string, emailNormalized: string,
productId: string productId: string
): Promise<WaitlistEntryDoc | null> { ): Promise<WaitlistEntryDoc | null> {
const { resources } = await container() return collection().findOne({
.items.query<WaitlistEntryDoc>({ filter: { emailNormalized, productId },
query: 'SELECT * FROM c WHERE c.emailNormalized = @email AND c.productId = @productId', });
parameters: [
{ name: '@email', value: emailNormalized },
{ name: '@productId', value: productId },
],
})
.fetchAll();
return resources[0] ?? null;
} }
export async function getByUnsubscribeToken(token: string): Promise<WaitlistEntryDoc | null> { export async function getByUnsubscribeToken(token: string): Promise<WaitlistEntryDoc | null> {
const { resources } = await container() return collection().findOne({
.items.query<WaitlistEntryDoc>({ filter: { unsubscribeToken: token },
query: 'SELECT * FROM c WHERE c.unsubscribeToken = @token', });
parameters: [{ name: '@token', value: token }],
})
.fetchAll();
return resources[0] ?? null;
} }
// ── List (admin, cross-partition) ── // ── List (admin, cross-partition) ──
@ -84,55 +64,32 @@ export async function list(opts: ListOptions): Promise<{
items: WaitlistEntryDoc[]; items: WaitlistEntryDoc[];
total: number; total: number;
}> { }> {
const conditions: string[] = []; const filter: FilterMap = {};
const params: SqlParameter[] = []; if (opts.productId) filter.productId = opts.productId;
if (opts.status) filter.status = opts.status;
if (opts.source) filter.source = opts.source;
if (opts.productId) { // Allowed sort columns (whitelist)
conditions.push('c.productId = @productId');
params.push({ name: '@productId', value: opts.productId });
}
if (opts.status) {
conditions.push('c.status = @status');
params.push({ name: '@status', value: opts.status });
}
if (opts.source) {
conditions.push('c.source = @source');
params.push({ name: '@source', value: opts.source });
}
if (opts.q) {
conditions.push('(CONTAINS(LOWER(c.email), @q) OR CONTAINS(LOWER(c.name), @q))');
params.push({ name: '@q', value: opts.q.toLowerCase() });
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Allowed sort columns (whitelist to prevent injection)
const sortCol = ['position', 'priority', 'createdAt'].includes(opts.sortBy) const sortCol = ['position', 'priority', 'createdAt'].includes(opts.sortBy)
? opts.sortBy ? opts.sortBy
: 'position'; : 'position';
const sortDir = opts.sortOrder === 'desc' ? 'DESC' : 'ASC'; const sortDir = opts.sortOrder === 'desc' ? -1 : 1;
// Count query let allDocs = await collection().findMany({
const { resources: countRes } = await container() filter,
.items.query<number>({ sort: { [sortCol]: sortDir },
query: `SELECT VALUE COUNT(1) FROM c ${where}`, });
parameters: [...params],
})
.fetchAll();
const total = countRes[0] ?? 0;
// Data query // In-memory text search
const dataParams: SqlParameter[] = [ if (opts.q) {
...params, const q = opts.q.toLowerCase();
{ name: '@offset', value: opts.offset }, allDocs = allDocs.filter(
{ name: '@limit', value: opts.limit }, d => d.email?.toLowerCase().includes(q) || d.name?.toLowerCase().includes(q)
]; );
const { resources: items } = await container() }
.items.query<WaitlistEntryDoc>({
query: `SELECT * FROM c ${where} ORDER BY c.${sortCol} ${sortDir} OFFSET @offset LIMIT @limit`, const total = allDocs.length;
parameters: dataParams, const items = allDocs.slice(opts.offset, opts.offset + opts.limit);
})
.fetchAll();
return { items, total }; return { items, total };
} }
@ -140,30 +97,21 @@ export async function list(opts: ListOptions): Promise<{
// ── Count ── // ── Count ──
export async function count(productId: string): Promise<number> { export async function count(productId: string): Promise<number> {
const { resources } = await container() return collection().count({ productId, status: { $ne: 'unsubscribed' } });
.items.query<number>({
query:
'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND c.status != @excluded',
parameters: [
{ name: '@productId', value: productId },
{ name: '@excluded', value: 'unsubscribed' },
],
})
.fetchAll();
return resources[0] ?? 0;
} }
// ── Next position ── // ── Next position ──
export async function getNextPosition(productId: string): Promise<number> { export async function getNextPosition(productId: string): Promise<number> {
const { resources } = await container() // Fetch all docs and find max position in-memory
.items.query<number>({ const docs = await collection().findMany({
query: 'SELECT VALUE MAX(c.position) FROM c WHERE c.productId = @productId', filter: { productId },
parameters: [{ name: '@productId', value: productId }], });
}) let maxPos = 0;
.fetchAll(); for (const d of docs) {
const maxPos = resources[0] ?? 0; if (typeof d.position === 'number' && d.position > maxPos) maxPos = d.position;
return (typeof maxPos === 'number' ? maxPos : 0) + 1; }
return maxPos + 1;
} }
// ── Update ── // ── Update ──
@ -174,11 +122,10 @@ export async function update(
updates: Partial<WaitlistEntryDoc> updates: Partial<WaitlistEntryDoc>
): Promise<WaitlistEntryDoc | null> { ): Promise<WaitlistEntryDoc | null> {
try { try {
const { resource: existing } = await container().item(id, email).read<WaitlistEntryDoc>(); return await collection().update(id, email, {
if (!existing) return null; ...updates,
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() }; updatedAt: new Date().toISOString(),
const { resource } = await container().item(id, email).replace(merged); });
return resource as WaitlistEntryDoc;
} catch { } catch {
return null; return null;
} }
@ -197,7 +144,7 @@ export async function unsubscribe(token: string): Promise<WaitlistEntryDoc | nul
export async function remove(id: string, email: string): Promise<boolean> { export async function remove(id: string, email: string): Promise<boolean> {
try { try {
await container().item(id, email).delete(); await collection().delete(id, email);
return true; return true;
} catch { } catch {
return false; return false;
@ -212,18 +159,12 @@ export async function getByStatus(
sortBy: 'position' | 'priority', sortBy: 'position' | 'priority',
limit: number limit: number
): Promise<WaitlistEntryDoc[]> { ): Promise<WaitlistEntryDoc[]> {
const sortCol = sortBy === 'priority' ? 'c.priority DESC' : 'c.position ASC'; const sortDir = sortBy === 'priority' ? -1 : 1;
const { resources } = await container() return collection().findMany({
.items.query<WaitlistEntryDoc>({ filter: { productId, status },
query: `SELECT * FROM c WHERE c.productId = @productId AND c.status = @status ORDER BY ${sortCol} OFFSET 0 LIMIT @limit`, sort: { [sortBy]: sortDir },
parameters: [ limit,
{ name: '@productId', value: productId }, });
{ name: '@status', value: status },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources;
} }
// ── Stats (admin analytics) ── // ── Stats (admin analytics) ──
@ -236,23 +177,20 @@ export interface WaitlistStats {
} }
export async function stats(productId: string): Promise<WaitlistStats> { export async function stats(productId: string): Promise<WaitlistStats> {
const { resources } = await container() const docs = await collection().findMany({
.items.query<WaitlistEntryDoc>({ filter: { productId },
query: 'SELECT c.status, c.source, c.createdAt FROM c WHERE c.productId = @productId', });
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
const byStatus: Record<string, number> = {}; const byStatus: Record<string, number> = {};
const bySource: Record<string, number> = {}; const bySource: Record<string, number> = {};
const todayStr = new Date().toISOString().slice(0, 10); // YYYY-MM-DD const todayStr = new Date().toISOString().slice(0, 10);
let todaySignups = 0; let todaySignups = 0;
for (const entry of resources) { for (const entry of docs) {
byStatus[entry.status] = (byStatus[entry.status] || 0) + 1; byStatus[entry.status] = (byStatus[entry.status] || 0) + 1;
bySource[entry.source] = (bySource[entry.source] || 0) + 1; bySource[entry.source] = (bySource[entry.source] || 0) + 1;
if (entry.createdAt.startsWith(todayStr)) todaySignups++; if (entry.createdAt.startsWith(todayStr)) todaySignups++;
} }
return { total: resources.length, byStatus, bySource, todaySignups }; return { total: docs.length, byStatus, bySource, todaySignups };
} }

View File

@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { getContainer } from '../../lib/cosmos.js'; import { getCollection } from '../../lib/datastore.js';
import type { import type {
WebhookSubscriptionDoc, WebhookSubscriptionDoc,
WebhookDeliveryDoc, WebhookDeliveryDoc,
@ -7,15 +7,14 @@ import type {
UpdateWebhookSubscriptionInput, UpdateWebhookSubscriptionInput,
} from './types.js'; } from './types.js';
const SUBSCRIPTIONS_CONTAINER = 'webhook_subscriptions'; function subsCollection() {
const DELIVERIES_CONTAINER = 'webhook_deliveries'; return getCollection<WebhookSubscriptionDoc>('webhook_subscriptions', '/productId');
function subscriptions() {
return getContainer(SUBSCRIPTIONS_CONTAINER);
} }
function deliveries() { // eslint-disable-next-line @typescript-eslint/no-explicit-any
return getContainer(DELIVERIES_CONTAINER); function deliveriesCollection(): import('@bytelyst/datastore').DocumentCollection<any> {
// WebhookDeliveryDoc uses pk instead of productId — doesn't satisfy BaseDocument
return getCollection('webhook_deliveries', '/pk');
} }
// ── Subscription CRUD ─────────────────────────────────────── // ── Subscription CRUD ───────────────────────────────────────
@ -38,8 +37,7 @@ export async function createSubscription(
updatedAt: now, updatedAt: now,
createdBy, createdBy,
}; };
const { resource } = await subscriptions().items.create(doc); return subsCollection().create(doc);
return resource as WebhookSubscriptionDoc;
} }
export async function getSubscription( export async function getSubscription(
@ -47,21 +45,18 @@ export async function getSubscription(
productId: string productId: string
): Promise<WebhookSubscriptionDoc | undefined> { ): Promise<WebhookSubscriptionDoc | undefined> {
try { try {
const { resource } = await subscriptions().item(id, productId).read<WebhookSubscriptionDoc>(); const doc = await subsCollection().findById(id, productId);
return resource ?? undefined; return doc ?? undefined;
} catch { } catch {
return undefined; return undefined;
} }
} }
export async function listSubscriptions(productId: string): Promise<WebhookSubscriptionDoc[]> { export async function listSubscriptions(productId: string): Promise<WebhookSubscriptionDoc[]> {
const { resources } = await subscriptions() return subsCollection().findMany({
.items.query<WebhookSubscriptionDoc>({ filter: { productId },
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC', sort: { createdAt: -1 },
parameters: [{ name: '@productId', value: productId }], });
})
.fetchAll();
return resources;
} }
export async function updateSubscription( export async function updateSubscription(
@ -72,21 +67,19 @@ export async function updateSubscription(
const existing = await getSubscription(id, productId); const existing = await getSubscription(id, productId);
if (!existing) return undefined; if (!existing) return undefined;
const updated: WebhookSubscriptionDoc = { const updates: Partial<WebhookSubscriptionDoc> = {
...existing,
...(input.url !== undefined && { url: input.url }), ...(input.url !== undefined && { url: input.url }),
...(input.events !== undefined && { events: input.events }), ...(input.events !== undefined && { events: input.events }),
...(input.enabled !== undefined && { enabled: input.enabled }), ...(input.enabled !== undefined && { enabled: input.enabled }),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
const { resource } = await subscriptions().item(id, productId).replace(updated); return subsCollection().update(id, productId, updates);
return resource as WebhookSubscriptionDoc;
} }
export async function deleteSubscription(id: string, productId: string): Promise<boolean> { export async function deleteSubscription(id: string, productId: string): Promise<boolean> {
try { try {
await subscriptions().item(id, productId).delete(); await subsCollection().delete(id, productId);
return true; return true;
} catch { } catch {
return false; return false;
@ -101,21 +94,25 @@ export async function incrementFailureCount(
const existing = await getSubscription(id, productId); const existing = await getSubscription(id, productId);
if (!existing) return; if (!existing) return;
existing.failureCount += 1; const now = new Date().toISOString();
if (disable) existing.enabled = false; await subsCollection().update(id, productId, {
existing.updatedAt = new Date().toISOString(); failureCount: existing.failureCount + 1,
existing.lastDeliveryAt = existing.updatedAt; ...(disable && { enabled: false }),
await subscriptions().item(id, productId).replace(existing); updatedAt: now,
lastDeliveryAt: now,
} as Partial<WebhookSubscriptionDoc>);
} }
export async function resetFailureCount(id: string, productId: string): Promise<void> { export async function resetFailureCount(id: string, productId: string): Promise<void> {
const existing = await getSubscription(id, productId); const existing = await getSubscription(id, productId);
if (!existing) return; if (!existing) return;
existing.failureCount = 0; const now = new Date().toISOString();
existing.lastDeliveryAt = new Date().toISOString(); await subsCollection().update(id, productId, {
existing.updatedAt = existing.lastDeliveryAt; failureCount: 0,
await subscriptions().item(id, productId).replace(existing); lastDeliveryAt: now,
updatedAt: now,
} as Partial<WebhookSubscriptionDoc>);
} }
// ── Subscription lookup by event ──────────────────────────── // ── Subscription lookup by event ────────────────────────────
@ -124,29 +121,21 @@ export async function findSubscriptionsForEvent(
productId: string, productId: string,
event: string event: string
): Promise<WebhookSubscriptionDoc[]> { ): Promise<WebhookSubscriptionDoc[]> {
const { resources } = await subscriptions() // Fetch enabled subscriptions and filter for array contains in-memory
.items.query<WebhookSubscriptionDoc>({ const all = await subsCollection().findMany({
query: filter: { productId, enabled: true },
'SELECT * FROM c WHERE c.productId = @productId AND c.enabled = true AND ARRAY_CONTAINS(c.events, @event)', });
parameters: [ return all.filter(s => (s.events as string[]).includes(event));
{ name: '@productId', value: productId },
{ name: '@event', value: event },
],
})
.fetchAll();
return resources;
} }
// ── Delivery Log ──────────────────────────────────────────── // ── Delivery Log ────────────────────────────────────────────
export async function createDelivery(doc: WebhookDeliveryDoc): Promise<WebhookDeliveryDoc> { export async function createDelivery(doc: WebhookDeliveryDoc): Promise<WebhookDeliveryDoc> {
const { resource } = await deliveries().items.create(doc); return deliveriesCollection().create(doc);
return resource as WebhookDeliveryDoc;
} }
export async function updateDelivery(doc: WebhookDeliveryDoc): Promise<WebhookDeliveryDoc> { export async function updateDelivery(doc: WebhookDeliveryDoc): Promise<WebhookDeliveryDoc> {
const { resource } = await deliveries().item(doc.id, doc.pk).replace(doc); return deliveriesCollection().upsert(doc);
return resource as WebhookDeliveryDoc;
} }
export async function listDeliveries( export async function listDeliveries(
@ -154,14 +143,9 @@ export async function listDeliveries(
options?: { limit?: number } options?: { limit?: number }
): Promise<WebhookDeliveryDoc[]> { ): Promise<WebhookDeliveryDoc[]> {
const limit = Math.min(options?.limit ?? 50, 200); const limit = Math.min(options?.limit ?? 50, 200);
const { resources } = await deliveries() return deliveriesCollection().findMany({
.items.query<WebhookDeliveryDoc>({ filter: { pk: { $startsWith: subscriptionId } },
query: 'SELECT TOP @limit * FROM c WHERE STARTSWITH(c.pk, @prefix) ORDER BY c.createdAt DESC', sort: { createdAt: -1 },
parameters: [ limit,
{ name: '@limit', value: limit }, });
{ name: '@prefix', value: subscriptionId },
],
})
.fetchAll();
return resources;
} }