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';
// Default TTL: 90 days in seconds
const DEFAULT_TTL = 90 * 24 * 60 * 60;
function container() {
return getContainer('audit_log');
function collection() {
return getCollection<AuditDoc>('audit_log', '/productId');
}
export async function create(doc: AuditDoc): Promise<AuditDoc> {
const { resource } = await container().items.create({
return collection().create({
...doc,
ttl: doc.ttl ?? DEFAULT_TTL,
});
return resource as AuditDoc;
}
export async function query(input: QueryAuditInput, productId: string): Promise<AuditDoc[]> {
const { userId, action, category, days, limit, offset } = input;
const since = new Date(Date.now() - days * 86400000).toISOString();
let queryText = 'SELECT * FROM c WHERE c.productId = @productId AND c.createdAt >= @since';
const parameters: { name: string; value: string | number }[] = [
{ name: '@productId', value: productId },
{ name: '@since', value: since },
];
const filter: FilterMap = {
productId,
createdAt: { $gte: since },
};
if (userId) filter.userId = userId;
if (action) filter.action = action;
if (category) filter.category = category;
if (userId) {
queryText += ' AND c.userId = @userId';
parameters.push({ name: '@userId', value: userId });
}
if (action) {
queryText += ' AND c.action = @action';
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;
const results = await collection().findMany({
filter,
sort: { createdAt: -1 },
limit: limit + offset,
});
return results.slice(offset);
}
export async function getStats(days = 30, productId?: string): Promise<Record<string, number>> {
const since = new Date(Date.now() - days * 86400000).toISOString();
const { resources } = await container()
.items.query<{ action: string; count: number }>({
query:
'SELECT c.action, COUNT(1) as count FROM c WHERE c.productId = @productId AND c.createdAt >= @since GROUP BY c.action',
parameters: [
{ name: '@productId', value: productId ?? '' },
{ name: '@since', value: since },
],
})
.fetchAll();
// Fetch docs and aggregate in-memory
const docs = await collection().findMany({
filter: {
productId: productId ?? '',
createdAt: { $gte: since },
},
});
const stats: Record<string, number> = {};
for (const r of resources) {
stats[r.action] = r.count;
for (const doc of docs) {
stats[doc.action] = (stats[doc.action] ?? 0) + 1;
}
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 { getContainer } from '../../lib/cosmos.js';
import { getCollection } from '../../lib/datastore.js';
import { createHash } from 'node:crypto';
import type { UserDoc, PasswordResetTokenDoc, EmailVerificationDoc } from './types.js';
function container() {
return getContainer('users');
function usersCollection() {
return getCollection<UserDoc>('users', '/id');
}
export async function getByEmail(email: string, productId: string): Promise<UserDoc | null> {
const { resources } = await container()
.items.query<UserDoc>({
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;
return usersCollection().findOne({
filter: { productId, email: email.toLowerCase() },
});
}
export async function getById(id: string): Promise<UserDoc | null> {
try {
const { resource } = await container().item(id, id).read<UserDoc>();
return resource ?? null;
return await usersCollection().findById(id, id);
} catch {
return null;
}
}
export async function create(user: UserDoc): Promise<UserDoc> {
const { resource } = await container().items.create(user);
return resource as UserDoc;
return usersCollection().create(user);
}
export async function updatePlan(
@ -44,17 +35,12 @@ export async function updatePlan(
plan: UserDoc['plan']
): Promise<UserDoc | null> {
try {
const { resource } = await container().item(id, id).read<UserDoc>();
if (!resource || resource.productId !== productId) return null;
const { resource: updated } = await container()
.item(id, id)
.replace<UserDoc>({
...resource,
plan,
updatedAt: new Date().toISOString(),
});
if (!updated) return null;
return updated;
const existing = await usersCollection().findById(id, id);
if (!existing || existing.productId !== productId) return null;
return await usersCollection().update(id, id, {
plan,
updatedAt: new Date().toISOString(),
} as Partial<UserDoc>);
} catch {
return null;
}
@ -62,16 +48,11 @@ export async function updatePlan(
export async function updateLastLogin(id: string): Promise<void> {
try {
const { resource } = await container().item(id, id).read<UserDoc>();
if (resource) {
await container()
.item(id, id)
.replace({
...resource,
lastLoginAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
const now = new Date().toISOString();
await usersCollection().update(id, id, {
lastLoginAt: now,
updatedAt: now,
} as Partial<UserDoc>);
} catch {
// Non-critical — don't throw
}
@ -80,39 +61,27 @@ export async function updateLastLogin(id: string): Promise<void> {
// ── Admin user management ────────────────────────────────────
export async function list(productId: string, limit = 100, offset = 0): Promise<UserDoc[]> {
const { resources } = await container()
.items.query<UserDoc>({
query:
'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit',
parameters: [
{ name: '@productId', value: productId },
{ name: '@offset', value: offset },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources;
const results = await usersCollection().findMany({
filter: { productId },
sort: { createdAt: -1 },
limit: limit + offset,
});
return results.slice(offset);
}
export async function count(productId: string): Promise<number> {
const { resources } = await container()
.items.query<number>({
query: 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId',
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
return resources[0] ?? 0;
return usersCollection().count({ productId });
}
export async function countByPlan(productId: string): Promise<Record<string, number>> {
const { resources } = await container()
.items.query<{ plan: string; cnt: number }>({
query: 'SELECT c.plan, COUNT(1) AS cnt FROM c WHERE c.productId = @productId GROUP BY c.plan',
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
// Fetch all users and aggregate in-memory
const docs = await usersCollection().findMany({
filter: { productId },
});
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;
}
@ -123,15 +92,10 @@ export async function update(
>
): Promise<UserDoc | null> {
try {
const { resource } = await container().item(id, id).read<UserDoc>();
if (!resource) return null;
const merged = {
...resource,
return await usersCollection().update(id, id, {
...updates,
updatedAt: new Date().toISOString(),
};
const { resource: updated } = await container().item(id, id).replace<UserDoc>(merged);
return updated ?? null;
} as Partial<UserDoc>);
} catch {
return null;
}
@ -139,7 +103,7 @@ export async function update(
export async function remove(id: string): Promise<boolean> {
try {
await container().item(id, id).delete();
await usersCollection().delete(id, id);
return true;
} catch {
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> {
try {
const { resource } = await container().item(id, id).read<UserDoc>();
if (!resource) return false;
await container()
.item(id, id)
.replace({
...resource,
passwordHash: newPasswordHash,
updatedAt: new Date().toISOString(),
});
await usersCollection().update(id, id, {
passwordHash: newPasswordHash,
updatedAt: new Date().toISOString(),
} as Partial<UserDoc>);
return true;
} catch {
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> {
try {
const { resource } = await container().item(id, id).read<UserDoc>();
if (!resource) return false;
await container()
.item(id, id)
.replace({
...resource,
emailVerified: verified,
updatedAt: new Date().toISOString(),
});
await usersCollection().update(id, id, {
emailVerified: verified,
updatedAt: new Date().toISOString(),
} as Partial<UserDoc>);
return true;
} catch {
return false;
@ -190,8 +144,8 @@ export async function setEmailVerified(id: string, verified: boolean): Promise<b
// ── Password Reset Tokens ────────────────────────────────────
function resetTokensContainer() {
return getContainer('password_reset_tokens');
function resetTokensCollection() {
return getCollection<PasswordResetTokenDoc>('password_reset_tokens', '/productId');
}
export function hashToken(token: string): string {
@ -199,87 +153,55 @@ export function hashToken(token: string): string {
}
export async function createResetToken(doc: PasswordResetTokenDoc): Promise<PasswordResetTokenDoc> {
const { resource } = await resetTokensContainer().items.create(doc);
return resource as PasswordResetTokenDoc;
return resetTokensCollection().create(doc);
}
export async function findResetToken(
tokenHash: string,
productId: string
): Promise<PasswordResetTokenDoc | null> {
const { resources } = await resetTokensContainer()
.items.query<PasswordResetTokenDoc>(
{
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;
return resetTokensCollection().findOne({
filter: { productId, tokenHash, usedAt: { $exists: false } },
});
}
export async function markResetTokenUsed(id: string, productId: string): Promise<void> {
const { resource } = await resetTokensContainer()
.item(id, productId)
.read<PasswordResetTokenDoc>();
if (resource) {
await resetTokensContainer()
.item(id, productId)
.replace({
...resource,
usedAt: new Date().toISOString(),
});
try {
await resetTokensCollection().update(id, productId, {
usedAt: new Date().toISOString(),
} as Partial<PasswordResetTokenDoc>);
} catch {
// best-effort
}
}
// ── Email Verification Tokens ────────────────────────────────
function emailVerificationsContainer() {
return getContainer('email_verifications');
function emailVerificationsCollection() {
return getCollection<EmailVerificationDoc>('email_verifications', '/productId');
}
export async function createEmailVerification(
doc: EmailVerificationDoc
): Promise<EmailVerificationDoc> {
const { resource } = await emailVerificationsContainer().items.create(doc);
return resource as EmailVerificationDoc;
return emailVerificationsCollection().create(doc);
}
export async function findEmailVerification(
tokenHash: string,
productId: string
): Promise<EmailVerificationDoc | null> {
const { resources } = await emailVerificationsContainer()
.items.query<EmailVerificationDoc>(
{
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;
return emailVerificationsCollection().findOne({
filter: { productId, tokenHash, verifiedAt: { $exists: false } },
});
}
export async function markEmailVerified(id: string, productId: string): Promise<void> {
const { resource } = await emailVerificationsContainer()
.item(id, productId)
.read<EmailVerificationDoc>();
if (resource) {
await emailVerificationsContainer()
.item(id, productId)
.replace({
...resource,
verifiedAt: new Date().toISOString(),
});
try {
await emailVerificationsCollection().update(id, productId, {
verifiedAt: new Date().toISOString(),
} as Partial<EmailVerificationDoc>);
} catch {
// best-effort
}
}

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

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';
function container() {
return getContainer('tracker_items');
function collection() {
return getCollection<TrackerItemDoc>('tracker_items', '/id');
}
export async function list(
query: ListItemsQuery
): Promise<{ items: TrackerItemDoc[]; total: number }> {
const conditions: string[] = [];
const params: { name: string; value: string | number }[] = [];
const filter: FilterMap = {};
if (query.productId) {
conditions.push('c.productId = @productId');
params.push({ name: '@productId', value: query.productId });
}
if (query.type) {
conditions.push('c.type = @type');
params.push({ name: '@type', value: query.type });
}
if (query.status) {
conditions.push('c.status = @status');
params.push({ name: '@status', value: query.status });
}
if (query.priority) {
conditions.push('c.priority = @priority');
params.push({ name: '@priority', value: query.priority });
}
if (query.assignee) {
conditions.push('c.assignee = @assignee');
params.push({ name: '@assignee', value: query.assignee });
}
if (query.productId) filter.productId = query.productId;
if (query.type) filter.type = query.type;
if (query.status) filter.status = query.status;
if (query.priority) filter.priority = query.priority;
if (query.assignee) filter.assignee = query.assignee;
if (query.visibility) filter.visibility = query.visibility;
// Fetch all matching docs — label and q filtering done in-memory
const sortField = query.sortBy === 'priority' ? 'priorityOrder' : query.sortBy;
const sortDir = query.sortOrder === 'desc' ? -1 : 1;
let allDocs = await collection().findMany({
filter,
sort: { [sortField]: sortDir },
});
// In-memory label filter (ARRAY_CONTAINS)
if (query.label) {
conditions.push('ARRAY_CONTAINS(c.labels, @label)');
params.push({ name: '@label', value: query.label });
}
if (query.visibility) {
conditions.push('c.visibility = @visibility');
params.push({ name: '@visibility', value: query.visibility });
const label = query.label;
allDocs = allDocs.filter(d => d.labels?.includes(label));
}
// In-memory text search
if (query.q) {
conditions.push(
'(CONTAINS(LOWER(c.title), LOWER(@q)) OR CONTAINS(LOWER(c.description), LOWER(@q)))'
const q = query.q.toLowerCase();
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
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 };
return { items, total };
}
export async function getById(id: string): Promise<TrackerItemDoc | null> {
try {
const { resource } = await container().item(id, id).read<TrackerItemDoc>();
return resource ?? null;
return await collection().findById(id, id);
} catch {
return null;
}
}
export async function create(doc: TrackerItemDoc): Promise<TrackerItemDoc> {
const { resource } = await container().items.create(doc);
return resource as TrackerItemDoc;
return collection().create(doc);
}
export async function update(
@ -99,11 +68,7 @@ export async function update(
updates: Partial<TrackerItemDoc>
): Promise<TrackerItemDoc | null> {
try {
const { resource: existing } = await container().item(id, id).read<TrackerItemDoc>();
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;
return await collection().update(id, id, { ...updates, updatedAt: new Date().toISOString() });
} catch {
return null;
}
@ -111,7 +76,7 @@ export async function update(
export async function remove(id: string): Promise<boolean> {
try {
await container().item(id, id).delete();
await collection().delete(id, id);
return true;
} catch {
return false;

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

View File

@ -1,13 +1,13 @@
/**
* Licenses repository Cosmos DB CRUD.
* Licenses repository cloud-agnostic via @bytelyst/datastore.
*/
import crypto from 'crypto';
import { getContainer } from '../../lib/cosmos.js';
import { getCollection } from '../../lib/datastore.js';
import type { LicenseDoc } from './types.js';
function container() {
return getContainer('licenses');
function collection() {
return getCollection<LicenseDoc>('licenses', '/userId');
}
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> {
const { resources } = await container()
.items.query<LicenseDoc>({
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;
return collection().findOne({
filter: { productId, key: key.toUpperCase() },
});
}
export async function getByUserId(userId: string, productId: string): Promise<LicenseDoc[]> {
const { resources } = await container()
.items.query<LicenseDoc>({
query:
'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;
return collection().findMany({
filter: { productId, userId },
sort: { createdAt: -1 },
});
}
export async function create(doc: LicenseDoc): Promise<LicenseDoc> {
const { resource } = await container().items.create(doc);
return resource as LicenseDoc;
return collection().create(doc);
}
export async function update(
@ -53,11 +38,10 @@ export async function update(
updates: Partial<LicenseDoc>
): Promise<LicenseDoc | null> {
try {
const { resource: existing } = await container().item(id, userId).read<LicenseDoc>();
if (!existing) return null;
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await container().item(id, userId).replace(merged);
return resource as LicenseDoc;
return await collection().update(id, userId, {
...updates,
updatedAt: new Date().toISOString(),
});
} catch {
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';
// ── Maintenance Config ───────────────────────────────────────
// Stored as a single document per product in the settings container.
// Uses the existing settings container (no new container needed).
const SETTINGS_CONTAINER = 'settings';
interface MaintenanceSettingsDoc {
id: string;
productId: string;
@ -16,8 +14,8 @@ interface MaintenanceSettingsDoc {
_etag?: string;
}
function settingsContainer() {
return getContainer(SETTINGS_CONTAINER);
function settingsCollection() {
return getCollection<MaintenanceSettingsDoc>('settings', '/productId');
}
const DEFAULT_CONFIG: MaintenanceConfig = {
@ -36,10 +34,8 @@ function docId(productId: string): string {
export async function getMaintenanceConfig(productId: string): Promise<MaintenanceConfig> {
try {
const { resource } = await settingsContainer()
.item(docId(productId), productId)
.read<MaintenanceSettingsDoc>();
return resource?.config ?? DEFAULT_CONFIG;
const doc = await settingsCollection().findById(docId(productId), productId);
return doc?.config ?? DEFAULT_CONFIG;
} catch {
return DEFAULT_CONFIG;
}
@ -57,54 +53,33 @@ export async function updateMaintenanceConfig(
config,
};
try {
const { resource: existing } = await settingsContainer().item(id, productId).read();
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;
const result = await settingsCollection().upsert(doc);
return result.config;
}
// ── Maintenance Windows ──────────────────────────────────────
const WINDOWS_CONTAINER = 'maintenance_windows';
function windowsContainer() {
return getContainer(WINDOWS_CONTAINER);
function windowsCollection() {
return getCollection<MaintenanceWindow>('maintenance_windows', '/productId');
}
export async function listUpcomingWindows(productId: string): Promise<MaintenanceWindow[]> {
const now = new Date().toISOString();
const { resources } = await windowsContainer()
.items.query<MaintenanceWindow>(
{
query:
'SELECT * FROM c WHERE c.productId = @productId AND c.scheduledEnd > @now ORDER BY c.scheduledStart ASC',
parameters: [
{ name: '@productId', value: productId },
{ name: '@now', value: now },
],
},
{ partitionKey: productId }
)
.fetchAll();
return resources;
// Fetch all for this product and filter in-memory for scheduledEnd > now
const all = await windowsCollection().findMany({
filter: { productId },
sort: { scheduledStart: 1 },
});
return all.filter(w => w.scheduledEnd > now);
}
export async function createWindow(doc: MaintenanceWindow): Promise<MaintenanceWindow> {
const { resource } = await windowsContainer().items.create(doc);
return resource as MaintenanceWindow;
return windowsCollection().create(doc);
}
export async function deleteWindow(id: string, productId: string): Promise<boolean> {
try {
await windowsContainer().item(id, productId).delete();
await windowsCollection().delete(id, productId);
return true;
} catch {
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';
const mockFetchAll = vi.fn();
const mockUpsert = vi.fn();
const mockDelete = vi.fn();
const mockRead = vi.fn();
const mockCreate = vi.fn();
vi.mock('../../lib/cosmos.js', () => ({
getContainer: vi.fn(() => ({
items: {
query: () => ({ fetchAll: mockFetchAll }),
upsert: mockUpsert,
create: mockCreate,
},
item: () => ({ delete: mockDelete, read: mockRead }),
})),
}));
import { getDevicesByUser, upsertDevice, removeDevice, getPrefs, upsertPrefs } from './repository.js';
import { describe, it, expect, beforeEach } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
import { setProvider } from '../../lib/datastore.js';
import {
getDevicesByUser,
upsertDevice,
removeDevice,
getPrefs,
upsertPrefs,
} from './repository.js';
import type { DeviceDoc, NotificationPrefsDoc } from './types.js';
const baseDevice: DeviceDoc = {
@ -49,18 +39,18 @@ const basePrefs: NotificationPrefsDoc = {
describe('notifications repository', () => {
beforeEach(() => {
vi.clearAllMocks();
setProvider(new MemoryDatastoreProvider());
});
describe('getDevicesByUser', () => {
it('returns devices for user', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseDevice] });
await upsertDevice(baseDevice);
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 () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getDevicesByUser('user_1', 'lysnrai');
expect(result).toEqual([]);
});
@ -68,41 +58,29 @@ describe('notifications repository', () => {
describe('upsertDevice', () => {
it('upserts and returns device', async () => {
mockUpsert.mockResolvedValue({ resource: baseDevice });
const result = await upsertDevice(baseDevice);
expect(result).toEqual(baseDevice);
expect(result.id).toBe('dev_1');
expect(result.platform).toBe('ios');
});
});
describe('removeDevice', () => {
it('returns true on successful delete', async () => {
mockDelete.mockResolvedValue(undefined);
await upsertDevice(baseDevice);
const result = await removeDevice('dev_1', 'user_1');
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', () => {
it('returns prefs when found', async () => {
mockRead.mockResolvedValue({ resource: basePrefs });
await upsertPrefs(basePrefs);
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 () => {
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');
expect(result).toBeNull();
});
@ -110,9 +88,9 @@ describe('notifications repository', () => {
describe('upsertPrefs', () => {
it('upserts and returns prefs', async () => {
mockUpsert.mockResolvedValue({ resource: 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';
function deviceContainer() {
return getContainer('devices');
function devicesCollection() {
return getCollection<DeviceDoc>('devices', '/userId');
}
function prefsContainer() {
return getContainer('notification_prefs');
function prefsCollection() {
return getCollection<NotificationPrefsDoc>('notification_prefs', '/userId');
}
// ── Devices ──
export async function getDevicesByUser(userId: string, productId: string): Promise<DeviceDoc[]> {
const { resources } = await deviceContainer()
.items.query<DeviceDoc>({
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId',
parameters: [
{ name: '@productId', value: productId },
{ name: '@userId', value: userId },
],
})
.fetchAll();
return resources;
return devicesCollection().findMany({
filter: { productId, userId },
});
}
export async function upsertDevice(doc: DeviceDoc): Promise<DeviceDoc> {
const { resource } = await deviceContainer().items.upsert<DeviceDoc>(doc);
return resource!;
return devicesCollection().upsert(doc);
}
export async function removeDevice(id: string, userId: string): Promise<boolean> {
try {
await deviceContainer().item(id, userId).delete();
await devicesCollection().delete(id, userId);
return true;
} catch {
return false;
@ -50,14 +42,12 @@ export async function getPrefs(
): Promise<NotificationPrefsDoc | null> {
const id = `prefs_${productId}_${userId}`;
try {
const { resource } = await prefsContainer().item(id, userId).read<NotificationPrefsDoc>();
return resource ?? null;
return await prefsCollection().findById(id, userId);
} catch {
return null;
}
}
export async function upsertPrefs(doc: NotificationPrefsDoc): Promise<NotificationPrefsDoc> {
const { resource } = await prefsContainer().items.upsert<NotificationPrefsDoc>(doc);
return resource!;
return prefsCollection().upsert(doc);
}

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';
const mockFetchAll = vi.fn();
const mockCreate = vi.fn();
const mockRead = vi.fn();
const mockReplace = vi.fn();
vi.mock('../../lib/cosmos.js', () => ({
getContainer: vi.fn(() => ({
items: {
query: () => ({ fetchAll: mockFetchAll }),
create: mockCreate,
},
item: () => ({ read: mockRead, replace: mockReplace }),
})),
}));
import { describe, it, expect, beforeEach } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
import { setProvider } from '../../lib/datastore.js';
import {
getByUserId,
getByStripeCustomerIdAnyProduct,
@ -61,18 +47,18 @@ const basePayment: PaymentDoc = {
describe('subscriptions repository', () => {
beforeEach(() => {
vi.clearAllMocks();
setProvider(new MemoryDatastoreProvider());
});
describe('getByUserId', () => {
it('returns subscription when found', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseSub] });
await createSubscription(baseSub);
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 () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getByUserId('user_1', 'lysnrai');
expect(result).toBeNull();
});
@ -80,13 +66,13 @@ describe('subscriptions repository', () => {
describe('getByStripeCustomerIdAnyProduct', () => {
it('returns subscription when found', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseSub] });
await createSubscription(baseSub);
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 () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getByStripeCustomerIdAnyProduct('cus_none');
expect(result).toBeNull();
});
@ -94,13 +80,13 @@ describe('subscriptions repository', () => {
describe('getByStripeCustomerId', () => {
it('returns subscription when found', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseSub] });
await createSubscription(baseSub);
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 () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getByStripeCustomerId('cus_none', 'lysnrai');
expect(result).toBeNull();
});
@ -108,29 +94,21 @@ describe('subscriptions repository', () => {
describe('createSubscription', () => {
it('creates and returns subscription', async () => {
mockCreate.mockResolvedValue({ resource: baseSub });
const result = await createSubscription(baseSub);
expect(result).toEqual(baseSub);
expect(result.id).toBe('sub_1');
expect(result.plan).toBe('pro');
});
});
describe('updateSubscription', () => {
it('merges updates and returns subscription', async () => {
mockRead.mockResolvedValue({ resource: baseSub });
const updated = { ...baseSub, plan: 'enterprise' as const };
mockReplace.mockResolvedValue({ resource: updated });
await createSubscription(baseSub);
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 () => {
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' });
expect(result).toBeNull();
});
@ -138,13 +116,13 @@ describe('subscriptions repository', () => {
describe('getPaymentsByUser', () => {
it('returns payments for user', async () => {
mockFetchAll.mockResolvedValue({ resources: [basePayment] });
await createPayment(basePayment);
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 () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getPaymentsByUser('user_1', 'lysnrai');
expect(result).toEqual([]);
});
@ -152,9 +130,9 @@ describe('subscriptions repository', () => {
describe('createPayment', () => {
it('creates and returns payment', async () => {
mockCreate.mockResolvedValue({ resource: 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';
function subContainer() {
return getContainer('subscriptions');
function subsCollection() {
return getCollection<SubscriptionDoc>('subscriptions', '/userId');
}
function payContainer() {
return getContainer('payments');
function paymentsCollection() {
return getCollection<PaymentDoc>('payments', '/userId');
}
// ── Subscriptions ──
@ -19,51 +19,33 @@ export async function getByUserId(
userId: string,
productId: string
): Promise<SubscriptionDoc | null> {
const { resources } = await subContainer()
.items.query<SubscriptionDoc>({
query:
'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;
return subsCollection().findOne({
filter: { productId, userId },
sort: { createdAt: -1 },
});
}
export async function getByStripeCustomerIdAnyProduct(
stripeCustomerId: string
): Promise<SubscriptionDoc | null> {
const { resources } = await subContainer()
.items.query<SubscriptionDoc>({
query: 'SELECT * FROM c WHERE c.stripeCustomerId = @cid ORDER BY c.createdAt DESC',
parameters: [{ name: '@cid', value: stripeCustomerId }],
})
.fetchAll();
return resources[0] ?? null;
return subsCollection().findOne({
filter: { stripeCustomerId },
sort: { createdAt: -1 },
});
}
export async function getByStripeCustomerId(
stripeCustomerId: string,
productId: string
): Promise<SubscriptionDoc | null> {
const { resources } = await subContainer()
.items.query<SubscriptionDoc>({
query:
'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;
return subsCollection().findOne({
filter: { productId, stripeCustomerId },
sort: { createdAt: -1 },
});
}
export async function createSubscription(doc: SubscriptionDoc): Promise<SubscriptionDoc> {
const { resource } = await subContainer().items.create(doc);
return resource as SubscriptionDoc;
return subsCollection().create(doc);
}
export async function updateSubscription(
@ -72,11 +54,10 @@ export async function updateSubscription(
updates: Partial<SubscriptionDoc>
): Promise<SubscriptionDoc | null> {
try {
const { resource: existing } = await subContainer().item(id, userId).read<SubscriptionDoc>();
if (!existing) return null;
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await subContainer().item(id, userId).replace(merged);
return resource as SubscriptionDoc;
return await subsCollection().update(id, userId, {
...updates,
updatedAt: new Date().toISOString(),
});
} catch {
return null;
}
@ -89,21 +70,13 @@ export async function getPaymentsByUser(
productId: string,
limit = 50
): Promise<PaymentDoc[]> {
const { resources } = await payContainer()
.items.query<PaymentDoc>({
query:
'SELECT * FROM c WHERE c.productId = @productId AND c.userId = @userId ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit',
parameters: [
{ name: '@productId', value: productId },
{ name: '@userId', value: userId },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources;
return paymentsCollection().findMany({
filter: { productId, userId },
sort: { createdAt: -1 },
limit,
});
}
export async function createPayment(doc: PaymentDoc): Promise<PaymentDoc> {
const { resource } = await payContainer().items.create(doc);
return resource as PaymentDoc;
return paymentsCollection().create(doc);
}

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 {
TelemetryEventDoc,
TelemetryCollectionPolicyDoc,
@ -10,31 +11,29 @@ import type {
TelemetryQueryInput,
} from './types.js';
// ─── Container accessors ────────────────────────────────────────────
// ─── Collection accessors ───────────────────────────────────────────
function eventsContainer() {
return getContainer('telemetry_events');
function eventsCollection() {
return getCollection<TelemetryEventDoc>('telemetry_events', '/pk');
}
function policiesContainer() {
return getContainer('telemetry_collection_policies');
function policiesCollection() {
return getCollection<TelemetryCollectionPolicyDoc>('telemetry_collection_policies', '/productId');
}
function clustersContainer() {
return getContainer('telemetry_error_clusters');
function clustersCollection() {
return getCollection<TelemetryErrorCluster>('telemetry_error_clusters', '/pk');
}
// ─── Events ─────────────────────────────────────────────────────────
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> {
// 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.
const promises = docs.map(doc => eventsContainer().items.upsert(doc));
const promises = docs.map(doc => eventsCollection().upsert(doc));
await Promise.all(promises);
}
@ -42,75 +41,31 @@ export async function queryEvents(
productId: string,
input: TelemetryQueryInput
): Promise<{ events: TelemetryEventDoc[]; continuationToken?: string }> {
const conditions: string[] = ['c.productId = @productId'];
const parameters: Array<{ name: string; value: string | number | boolean }> = [
{ name: '@productId', value: productId },
];
const filter: FilterMap = { productId };
if (input.userId) {
conditions.push('c.userId = @userId');
parameters.push({ name: '@userId', value: input.userId });
}
if (input.anonymousInstallId) {
conditions.push('c.anonymousInstallId = @anonymousInstallId');
parameters.push({ name: '@anonymousInstallId', value: input.anonymousInstallId });
}
if (input.platform) {
conditions.push('c.platform = @platform');
parameters.push({ name: '@platform', value: input.platform });
}
if (input.channel) {
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 });
}
if (input.userId) filter.userId = input.userId;
if (input.anonymousInstallId) filter.anonymousInstallId = input.anonymousInstallId;
if (input.platform) filter.platform = input.platform;
if (input.channel) filter.channel = input.channel;
if (input.osFamily) filter.osFamily = input.osFamily;
if (input.appVersion) filter.appVersion = input.appVersion;
if (input.buildNumber) filter.buildNumber = input.buildNumber;
if (input.module) filter.module = input.module;
if (input.eventName) filter.eventName = input.eventName;
if (input.eventType) filter.eventType = input.eventType;
if (input.from)
filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $gte: input.from };
if (input.to) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $lte: 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>(
{ query, parameters },
{
maxItemCount: input.limit,
continuationToken: input.continuationToken || undefined,
}
);
const { resources, continuationToken } = await iterator.fetchNext();
return {
events: resources,
continuationToken: continuationToken || undefined,
};
return { events, continuationToken: undefined };
}
export async function queryGeoDistribution(
@ -118,46 +73,38 @@ export async function queryGeoDistribution(
from?: string,
to?: string
): Promise<Array<{ countryCode: string; count: number }>> {
const conditions: string[] = [
'c.productId = @productId',
'IS_DEFINED(c.countryCode)',
'c.countryCode != null',
];
const parameters: Array<{ name: string; value: string }> = [
{ name: '@productId', value: productId },
];
if (from) {
conditions.push('c.occurredAt >= @from');
parameters.push({ name: '@from', value: from });
}
if (to) {
conditions.push('c.occurredAt <= @to');
parameters.push({ name: '@to', value: to });
const filter: FilterMap = {
productId,
countryCode: { $exists: true },
};
if (from) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $gte: from };
if (to) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $lte: to };
// Fetch docs and aggregate in-memory
const docs = await eventsCollection().findMany({ filter });
const counts: Record<string, number> = {};
for (const doc of docs) {
if (doc.countryCode) {
counts[doc.countryCode] = (counts[doc.countryCode] ?? 0) + 1;
}
}
const query = `SELECT c.countryCode, COUNT(1) AS count FROM c WHERE ${conditions.join(' AND ')} GROUP BY c.countryCode`;
const { resources } = await eventsContainer()
.items.query<{ countryCode: string; count: number }>({ query, parameters })
.fetchAll();
return resources.sort((a, b) => b.count - a.count);
return Object.entries(counts)
.map(([countryCode, count]) => ({ countryCode, count }))
.sort((a, b) => b.count - a.count);
}
export async function deleteEventsByUserId(productId: string, userId: string): Promise<number> {
// Find all events for this user, then delete them
const { resources } = await eventsContainer()
.items.query<{ id: string; pk: string }>({
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();
const docs = await eventsCollection().findMany({
filter: { productId, userId },
});
let deleted = 0;
for (const doc of resources) {
for (const doc of docs) {
try {
await eventsContainer().item(doc.id, doc.pk).delete();
await eventsCollection().delete(doc.id, doc.pk);
deleted++;
} catch {
// Skip docs that fail to delete (already deleted, etc.)
@ -169,13 +116,10 @@ export async function deleteEventsByUserId(productId: string, userId: string): P
// ─── Policies ───────────────────────────────────────────────────────
export async function listPolicies(productId: string): Promise<TelemetryCollectionPolicyDoc[]> {
const { resources } = await policiesContainer()
.items.query<TelemetryCollectionPolicyDoc>({
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.priority DESC',
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
return resources;
return policiesCollection().findMany({
filter: { productId },
sort: { priority: -1 },
});
}
export async function getPolicy(
@ -183,10 +127,7 @@ export async function getPolicy(
productId: string
): Promise<TelemetryCollectionPolicyDoc | null> {
try {
const { resource } = await policiesContainer()
.item(id, productId)
.read<TelemetryCollectionPolicyDoc>();
return resource ?? null;
return await policiesCollection().findById(id, productId);
} catch {
return null;
}
@ -195,8 +136,7 @@ export async function getPolicy(
export async function createPolicy(
doc: TelemetryCollectionPolicyDoc
): Promise<TelemetryCollectionPolicyDoc> {
const { resource } = await policiesContainer().items.create(doc);
return resource as TelemetryCollectionPolicyDoc;
return policiesCollection().create(doc);
}
export async function updatePolicy(
@ -205,13 +145,10 @@ export async function updatePolicy(
updates: Partial<TelemetryCollectionPolicyDoc>
): Promise<TelemetryCollectionPolicyDoc | null> {
try {
const { resource: existing } = await policiesContainer()
.item(id, productId)
.read<TelemetryCollectionPolicyDoc>();
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;
return await policiesCollection().update(id, productId, {
...updates,
updatedAt: new Date().toISOString(),
});
} catch {
return null;
}
@ -219,7 +156,7 @@ export async function updatePolicy(
export async function deletePolicy(id: string, productId: string): Promise<boolean> {
try {
await policiesContainer().item(id, productId).delete();
await policiesCollection().delete(id, productId);
return true;
} catch {
return false;
@ -238,45 +175,29 @@ export async function listClusters(
limit?: number;
}
): Promise<TelemetryErrorCluster[]> {
const conditions: string[] = ['c.productId = @productId'];
const parameters: Array<{ name: string; value: string | number | boolean }> = [
{ name: '@productId', value: productId },
];
const filter: FilterMap = { productId };
if (filters?.platform) {
conditions.push('c.platform = @platform');
parameters.push({ name: '@platform', value: filters.platform });
}
if (filters?.module) {
conditions.push('c.module = @module');
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 });
}
if (filters?.platform) filter.platform = filters.platform;
if (filters?.module) filter.module = filters.module;
if (filters?.from)
filter.lastSeenAt = { ...((filter.lastSeenAt as object) ?? {}), $gte: filters.from };
if (filters?.to)
filter.lastSeenAt = { ...((filter.lastSeenAt as object) ?? {}), $lte: filters.to };
const limit = filters?.limit ?? 50;
const query = `SELECT TOP ${limit} * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.totalCount DESC`;
const { resources } = await clustersContainer()
.items.query<TelemetryErrorCluster>({ query, parameters })
.fetchAll();
return resources;
return clustersCollection().findMany({
filter,
sort: { totalCount: -1 },
limit: filters?.limit ?? 50,
});
}
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> {
try {
const { resource } = await clustersContainer().item(id, pk).read<TelemetryErrorCluster>();
return resource ?? null;
return await clustersCollection().findById(id, pk);
} catch {
return null;
}
@ -288,13 +209,7 @@ export async function updateCluster(
updates: Partial<TelemetryErrorCluster>
): Promise<TelemetryErrorCluster | null> {
try {
const { resource: existing } = await clustersContainer()
.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;
return await clustersCollection().update(id, pk, updates);
} catch {
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';
const mockFetchAll = vi.fn();
const mockCreate = vi.fn();
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 { describe, it, expect, beforeEach } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
import { setProvider } from '../../lib/datastore.js';
import { list, listByUser, getById, create, revoke, remove, countActive } from './repository.js';
import type { ApiTokenDoc } from './types.js';
const baseToken: ApiTokenDoc = {
@ -44,19 +25,18 @@ const baseToken: ApiTokenDoc = {
describe('tokens repository', () => {
beforeEach(() => {
vi.clearAllMocks();
setProvider(new MemoryDatastoreProvider());
});
describe('list', () => {
it('returns tokens without hashes', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseToken] });
await create(baseToken);
const result = await list('lysnrai');
expect(result).toHaveLength(1);
expect(result[0]).not.toHaveProperty('tokenHash');
});
it('returns empty array when no tokens', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list('lysnrai');
expect(result).toEqual([]);
});
@ -64,7 +44,7 @@ describe('tokens repository', () => {
describe('listByUser', () => {
it('returns tokens for user without hashes', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseToken] });
await create(baseToken);
const result = await listByUser('user_1', 'lysnrai');
expect(result).toHaveLength(1);
expect(result[0]).not.toHaveProperty('tokenHash');
@ -73,19 +53,13 @@ describe('tokens repository', () => {
describe('getById', () => {
it('returns token when found', async () => {
mockRead.mockResolvedValue({ resource: baseToken });
await create(baseToken);
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 () => {
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');
expect(result).toBeNull();
});
@ -93,7 +67,6 @@ describe('tokens repository', () => {
describe('create', () => {
it('creates and returns token without hash', async () => {
mockCreate.mockResolvedValue({ resource: baseToken });
const result = await create(baseToken);
expect(result).not.toHaveProperty('tokenHash');
expect(result.id).toBe('tok_1');
@ -102,14 +75,14 @@ describe('tokens repository', () => {
describe('revoke', () => {
it('revokes an existing token', async () => {
mockRead.mockResolvedValue({ resource: baseToken });
mockReplace.mockResolvedValue({ resource: { ...baseToken, status: 'revoked' } });
await create(baseToken);
const result = await revoke('tok_1', 'user_1');
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 () => {
mockRead.mockRejectedValue(new Error('Not found'));
const result = await revoke('tok_1', 'user_1');
expect(result).toBe(false);
});
@ -117,36 +90,22 @@ describe('tokens repository', () => {
describe('remove', () => {
it('deletes and returns true', async () => {
mockDelete.mockResolvedValue(undefined);
await create(baseToken);
const result = await remove('tok_1', 'user_1');
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', () => {
it('returns count', async () => {
mockFetchAll.mockResolvedValue({ resources: [5] });
await create(baseToken);
const result = await countActive('lysnrai');
expect(result).toBe(5);
expect(result).toBe(1);
});
it('returns 0 when no results', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await countActive('lysnrai');
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 { getContainer } from '../../lib/cosmos.js';
import { getCollection } from '../../lib/datastore.js';
import type { ApiTokenDoc, ApiTokenResponse } from './types.js';
function container() {
return getContainer('api_tokens');
function collection() {
return getCollection<ApiTokenDoc>('api_tokens', '/userId');
}
function stripHash(doc: ApiTokenDoc): ApiTokenResponse {
@ -17,63 +17,46 @@ function stripHash(doc: ApiTokenDoc): ApiTokenResponse {
}
export async function list(productId: string, limit = 100): Promise<ApiTokenResponse[]> {
const { resources } = await container()
.items.query<ApiTokenDoc>({
query:
"SELECT * FROM c WHERE c.productId = @productId AND c.status != 'expired' ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit",
parameters: [
{ name: '@productId', value: productId },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources.map(stripHash);
const results = await collection().findMany({
filter: { productId, status: { $ne: 'expired' } },
sort: { createdAt: -1 },
limit,
});
return results.map(stripHash);
}
export async function listByUser(userId: string, productId: string): Promise<ApiTokenResponse[]> {
const { resources } = await container()
.items.query<ApiTokenDoc>({
query:
'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.map(stripHash);
const results = await collection().findMany({
filter: { productId, userId },
sort: { createdAt: -1 },
});
return results.map(stripHash);
}
export async function getById(id: string, userId: string): Promise<ApiTokenDoc | null> {
try {
const { resource } = await container().item(id, userId).read<ApiTokenDoc>();
return resource ?? null;
return await collection().findById(id, userId);
} catch {
return null;
}
}
export async function create(doc: ApiTokenDoc): Promise<ApiTokenResponse> {
const { resource } = await container().items.create<ApiTokenDoc>(doc);
return stripHash(resource!);
const created = await collection().create(doc);
return stripHash(created);
}
export async function revoke(id: string, userId: string): Promise<boolean> {
const existing = await getById(id, userId);
if (!existing) return false;
await container()
.item(id, userId)
.replace<ApiTokenDoc>({
...existing,
status: 'revoked',
});
await collection().update(id, userId, { status: 'revoked' } as Partial<ApiTokenDoc>);
return true;
}
export async function remove(id: string, userId: string): Promise<boolean> {
try {
await container().item(id, userId).delete();
await collection().delete(id, userId);
return true;
} catch {
return false;
@ -81,13 +64,7 @@ export async function remove(id: string, userId: string): Promise<boolean> {
}
export async function countActive(productId: string): Promise<number> {
const { resources } = await container()
.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;
return collection().count({ productId, status: 'active' });
}
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';
const mockFetchAll = vi.fn();
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 { describe, it, expect, beforeEach } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
import { setProvider } from '../../lib/datastore.js';
import { getByDate, list, upsert, getMonthlyUsage } from './repository.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 = {
id: 'usg_2026-02-16_user_1',
id: `usg_${todayStr}_user_1`,
productId: 'lysnrai',
userId: 'user_1',
date: '2026-02-16',
date: todayStr,
dictations: 5,
words: 250,
durationMs: 30000,
tokensUsed: 1200,
costUsd: 0.01,
createdAt: '2026-02-16T00:00:00Z',
createdAt: `${todayStr}T00:00:00Z`,
};
describe('usage repository', () => {
beforeEach(() => {
vi.clearAllMocks();
setProvider(new MemoryDatastoreProvider());
});
describe('getByDate', () => {
it('returns usage when found', async () => {
mockRead.mockResolvedValue({ resource: baseUsage });
const result = await getByDate('user_1', '2026-02-16');
expect(result).toEqual(baseUsage);
await upsert(baseUsage);
const result = await getByDate('user_1', todayStr);
expect(result).not.toBeNull();
expect(result!.dictations).toBe(5);
});
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');
expect(result).toBeNull();
});
@ -61,19 +45,18 @@ describe('usage repository', () => {
describe('list', () => {
it('returns usage records', async () => {
mockFetchAll.mockResolvedValue({ resources: [baseUsage] });
await upsert(baseUsage);
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 () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list({});
expect(result).toEqual([]);
});
it('uses default values for days and limit', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await list();
expect(result).toEqual([]);
});
@ -81,30 +64,26 @@ describe('usage repository', () => {
describe('upsert', () => {
it('upserts and returns usage', async () => {
mockUpsert.mockResolvedValue({ resource: baseUsage });
const result = await upsert(baseUsage);
expect(result).toEqual(baseUsage);
expect(result.id).toBe(baseUsage.id);
expect(result.words).toBe(250);
});
});
describe('getMonthlyUsage', () => {
it('returns aggregated monthly usage', async () => {
mockFetchAll.mockResolvedValue({
resources: [{ totalTokens: 5000, totalWords: 2500, totalDictations: 20 }],
});
await upsert(baseUsage);
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 () => {
mockFetchAll.mockResolvedValue({ resources: [undefined] });
const result = await getMonthlyUsage('user_1', 'lysnrai');
expect(result).toEqual({ tokens: 0, words: 0, dictations: 0 });
});
it('returns zeros when empty resources', async () => {
mockFetchAll.mockResolvedValue({ resources: [] });
const result = await getMonthlyUsage('user_1', 'lysnrai');
const result = await getMonthlyUsage('nonexistent', 'lysnrai');
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';
function container() {
return getContainer('usage_daily');
function collection() {
return getCollection<UsageDoc>('usage_daily', '/userId');
}
export async function getByDate(userId: string, date: string): Promise<UsageDoc | null> {
const id = `usg_${date}_${userId}`;
try {
const { resource } = await container().item(id, userId).read<UsageDoc>();
return resource ?? null;
return await collection().findById(id, userId);
} catch {
return null;
}
@ -25,59 +24,43 @@ export async function list(
const { userId, days = 30, limit = 100, productId } = options;
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
let queryText = 'SELECT * FROM c WHERE c.date >= @since';
const parameters: { name: string; value: string | number }[] = [{ name: '@since', value: since }];
// Fetch all matching docs and filter in-memory for $gte on date
const filter: Record<string, unknown> = { date: { $gte: since } };
if (productId) filter.productId = productId;
if (userId) filter.userId = userId;
if (productId) {
queryText += ' AND c.productId = @productId';
parameters.push({ name: '@productId', value: productId });
}
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;
return collection().findMany({
filter: filter as import('@bytelyst/datastore').FilterMap,
sort: { date: -1 },
limit,
});
}
export async function upsert(doc: UsageDoc): Promise<UsageDoc> {
const { resource } = await container().items.upsert<UsageDoc>(doc);
return resource!;
return collection().upsert(doc);
}
export async function getMonthlyUsage(userId: string, productId: string): Promise<MonthlyUsage> {
const now = new Date();
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
const query =
'SELECT VALUE {' +
' totalTokens: SUM(c.tokensUsed), ' +
' totalWords: SUM(c.words), ' +
' totalDictations: SUM(c.dictations)' +
'} FROM c WHERE c.productId = @productId AND c.userId = @uid AND c.date >= @since';
// Fetch docs for this month and aggregate in-memory
const docs = await collection().findMany({
filter: {
productId,
userId,
date: { $gte: monthStart },
} as import('@bytelyst/datastore').FilterMap,
});
const { resources } = await container()
.items.query<{ totalTokens: number; totalWords: number; totalDictations: number }>({
query,
parameters: [
{ name: '@productId', value: productId },
{ name: '@uid', value: userId },
{ name: '@since', value: monthStart },
],
})
.fetchAll();
let tokens = 0;
let words = 0;
let dictations = 0;
for (const doc of docs) {
tokens += doc.tokensUsed ?? 0;
words += doc.words ?? 0;
dictations += doc.dictations ?? 0;
}
const row = resources[0];
return {
tokens: row?.totalTokens ?? 0,
words: row?.totalWords ?? 0,
dictations: row?.totalDictations ?? 0,
};
return { tokens, words, dictations };
}

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).
*/
import type { SqlParameter } from '@azure/cosmos';
import { getContainer } from '../../lib/cosmos.js';
import type { FilterMap } from '@bytelyst/datastore';
import { getCollection } from '../../lib/datastore.js';
import type { WaitlistEntryDoc, WaitlistStatus, WaitlistSource } from './types.js';
const CONTAINER = 'waitlist';
function container() {
return getContainer(CONTAINER);
function collection() {
return getCollection<WaitlistEntryDoc>('waitlist', '/email');
}
// ── Normalize email for case-insensitive dedup ──
@ -24,47 +22,29 @@ export function normalizeEmail(email: string): string {
// ── Create ──
export async function create(doc: WaitlistEntryDoc): Promise<WaitlistEntryDoc> {
const { resource } = await container().items.create(doc);
return resource as WaitlistEntryDoc;
return collection().create(doc);
}
// ── Read (single) ──
export async function getById(id: string): Promise<WaitlistEntryDoc | null> {
// id-based lookup requires cross-partition query (partition is /email)
const { resources } = await container()
.items.query<WaitlistEntryDoc>({
query: 'SELECT * FROM c WHERE c.id = @id',
parameters: [{ name: '@id', value: id }],
})
.fetchAll();
return resources[0] ?? null;
// Cross-partition lookup by id
return collection().findOne({ filter: { id } });
}
export async function getByEmail(
emailNormalized: string,
productId: string
): Promise<WaitlistEntryDoc | null> {
const { resources } = await container()
.items.query<WaitlistEntryDoc>({
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;
return collection().findOne({
filter: { emailNormalized, productId },
});
}
export async function getByUnsubscribeToken(token: string): Promise<WaitlistEntryDoc | null> {
const { resources } = await container()
.items.query<WaitlistEntryDoc>({
query: 'SELECT * FROM c WHERE c.unsubscribeToken = @token',
parameters: [{ name: '@token', value: token }],
})
.fetchAll();
return resources[0] ?? null;
return collection().findOne({
filter: { unsubscribeToken: token },
});
}
// ── List (admin, cross-partition) ──
@ -84,55 +64,32 @@ export async function list(opts: ListOptions): Promise<{
items: WaitlistEntryDoc[];
total: number;
}> {
const conditions: string[] = [];
const params: SqlParameter[] = [];
const filter: FilterMap = {};
if (opts.productId) filter.productId = opts.productId;
if (opts.status) filter.status = opts.status;
if (opts.source) filter.source = opts.source;
if (opts.productId) {
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)
// Allowed sort columns (whitelist)
const sortCol = ['position', 'priority', 'createdAt'].includes(opts.sortBy)
? opts.sortBy
: 'position';
const sortDir = opts.sortOrder === 'desc' ? 'DESC' : 'ASC';
const sortDir = opts.sortOrder === 'desc' ? -1 : 1;
// Count query
const { resources: countRes } = await container()
.items.query<number>({
query: `SELECT VALUE COUNT(1) FROM c ${where}`,
parameters: [...params],
})
.fetchAll();
const total = countRes[0] ?? 0;
let allDocs = await collection().findMany({
filter,
sort: { [sortCol]: sortDir },
});
// Data query
const dataParams: SqlParameter[] = [
...params,
{ name: '@offset', value: opts.offset },
{ name: '@limit', value: opts.limit },
];
const { resources: items } = await container()
.items.query<WaitlistEntryDoc>({
query: `SELECT * FROM c ${where} ORDER BY c.${sortCol} ${sortDir} OFFSET @offset LIMIT @limit`,
parameters: dataParams,
})
.fetchAll();
// In-memory text search
if (opts.q) {
const q = opts.q.toLowerCase();
allDocs = allDocs.filter(
d => d.email?.toLowerCase().includes(q) || d.name?.toLowerCase().includes(q)
);
}
const total = allDocs.length;
const items = allDocs.slice(opts.offset, opts.offset + opts.limit);
return { items, total };
}
@ -140,30 +97,21 @@ export async function list(opts: ListOptions): Promise<{
// ── Count ──
export async function count(productId: string): Promise<number> {
const { resources } = await container()
.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;
return collection().count({ productId, status: { $ne: 'unsubscribed' } });
}
// ── Next position ──
export async function getNextPosition(productId: string): Promise<number> {
const { resources } = await container()
.items.query<number>({
query: 'SELECT VALUE MAX(c.position) FROM c WHERE c.productId = @productId',
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
const maxPos = resources[0] ?? 0;
return (typeof maxPos === 'number' ? maxPos : 0) + 1;
// Fetch all docs and find max position in-memory
const docs = await collection().findMany({
filter: { productId },
});
let maxPos = 0;
for (const d of docs) {
if (typeof d.position === 'number' && d.position > maxPos) maxPos = d.position;
}
return maxPos + 1;
}
// ── Update ──
@ -174,11 +122,10 @@ export async function update(
updates: Partial<WaitlistEntryDoc>
): Promise<WaitlistEntryDoc | null> {
try {
const { resource: existing } = await container().item(id, email).read<WaitlistEntryDoc>();
if (!existing) return null;
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
const { resource } = await container().item(id, email).replace(merged);
return resource as WaitlistEntryDoc;
return await collection().update(id, email, {
...updates,
updatedAt: new Date().toISOString(),
});
} catch {
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> {
try {
await container().item(id, email).delete();
await collection().delete(id, email);
return true;
} catch {
return false;
@ -212,18 +159,12 @@ export async function getByStatus(
sortBy: 'position' | 'priority',
limit: number
): Promise<WaitlistEntryDoc[]> {
const sortCol = sortBy === 'priority' ? 'c.priority DESC' : 'c.position ASC';
const { resources } = await container()
.items.query<WaitlistEntryDoc>({
query: `SELECT * FROM c WHERE c.productId = @productId AND c.status = @status ORDER BY ${sortCol} OFFSET 0 LIMIT @limit`,
parameters: [
{ name: '@productId', value: productId },
{ name: '@status', value: status },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources;
const sortDir = sortBy === 'priority' ? -1 : 1;
return collection().findMany({
filter: { productId, status },
sort: { [sortBy]: sortDir },
limit,
});
}
// ── Stats (admin analytics) ──
@ -236,23 +177,20 @@ export interface WaitlistStats {
}
export async function stats(productId: string): Promise<WaitlistStats> {
const { resources } = await container()
.items.query<WaitlistEntryDoc>({
query: 'SELECT c.status, c.source, c.createdAt FROM c WHERE c.productId = @productId',
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
const docs = await collection().findMany({
filter: { productId },
});
const byStatus: 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;
for (const entry of resources) {
for (const entry of docs) {
byStatus[entry.status] = (byStatus[entry.status] || 0) + 1;
bySource[entry.source] = (bySource[entry.source] || 0) + 1;
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 { getContainer } from '../../lib/cosmos.js';
import { getCollection } from '../../lib/datastore.js';
import type {
WebhookSubscriptionDoc,
WebhookDeliveryDoc,
@ -7,15 +7,14 @@ import type {
UpdateWebhookSubscriptionInput,
} from './types.js';
const SUBSCRIPTIONS_CONTAINER = 'webhook_subscriptions';
const DELIVERIES_CONTAINER = 'webhook_deliveries';
function subscriptions() {
return getContainer(SUBSCRIPTIONS_CONTAINER);
function subsCollection() {
return getCollection<WebhookSubscriptionDoc>('webhook_subscriptions', '/productId');
}
function deliveries() {
return getContainer(DELIVERIES_CONTAINER);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function deliveriesCollection(): import('@bytelyst/datastore').DocumentCollection<any> {
// WebhookDeliveryDoc uses pk instead of productId — doesn't satisfy BaseDocument
return getCollection('webhook_deliveries', '/pk');
}
// ── Subscription CRUD ───────────────────────────────────────
@ -38,8 +37,7 @@ export async function createSubscription(
updatedAt: now,
createdBy,
};
const { resource } = await subscriptions().items.create(doc);
return resource as WebhookSubscriptionDoc;
return subsCollection().create(doc);
}
export async function getSubscription(
@ -47,21 +45,18 @@ export async function getSubscription(
productId: string
): Promise<WebhookSubscriptionDoc | undefined> {
try {
const { resource } = await subscriptions().item(id, productId).read<WebhookSubscriptionDoc>();
return resource ?? undefined;
const doc = await subsCollection().findById(id, productId);
return doc ?? undefined;
} catch {
return undefined;
}
}
export async function listSubscriptions(productId: string): Promise<WebhookSubscriptionDoc[]> {
const { resources } = await subscriptions()
.items.query<WebhookSubscriptionDoc>({
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC',
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
return resources;
return subsCollection().findMany({
filter: { productId },
sort: { createdAt: -1 },
});
}
export async function updateSubscription(
@ -72,21 +67,19 @@ export async function updateSubscription(
const existing = await getSubscription(id, productId);
if (!existing) return undefined;
const updated: WebhookSubscriptionDoc = {
...existing,
const updates: Partial<WebhookSubscriptionDoc> = {
...(input.url !== undefined && { url: input.url }),
...(input.events !== undefined && { events: input.events }),
...(input.enabled !== undefined && { enabled: input.enabled }),
updatedAt: new Date().toISOString(),
};
const { resource } = await subscriptions().item(id, productId).replace(updated);
return resource as WebhookSubscriptionDoc;
return subsCollection().update(id, productId, updates);
}
export async function deleteSubscription(id: string, productId: string): Promise<boolean> {
try {
await subscriptions().item(id, productId).delete();
await subsCollection().delete(id, productId);
return true;
} catch {
return false;
@ -101,21 +94,25 @@ export async function incrementFailureCount(
const existing = await getSubscription(id, productId);
if (!existing) return;
existing.failureCount += 1;
if (disable) existing.enabled = false;
existing.updatedAt = new Date().toISOString();
existing.lastDeliveryAt = existing.updatedAt;
await subscriptions().item(id, productId).replace(existing);
const now = new Date().toISOString();
await subsCollection().update(id, productId, {
failureCount: existing.failureCount + 1,
...(disable && { enabled: false }),
updatedAt: now,
lastDeliveryAt: now,
} as Partial<WebhookSubscriptionDoc>);
}
export async function resetFailureCount(id: string, productId: string): Promise<void> {
const existing = await getSubscription(id, productId);
if (!existing) return;
existing.failureCount = 0;
existing.lastDeliveryAt = new Date().toISOString();
existing.updatedAt = existing.lastDeliveryAt;
await subscriptions().item(id, productId).replace(existing);
const now = new Date().toISOString();
await subsCollection().update(id, productId, {
failureCount: 0,
lastDeliveryAt: now,
updatedAt: now,
} as Partial<WebhookSubscriptionDoc>);
}
// ── Subscription lookup by event ────────────────────────────
@ -124,29 +121,21 @@ export async function findSubscriptionsForEvent(
productId: string,
event: string
): Promise<WebhookSubscriptionDoc[]> {
const { resources } = await subscriptions()
.items.query<WebhookSubscriptionDoc>({
query:
'SELECT * FROM c WHERE c.productId = @productId AND c.enabled = true AND ARRAY_CONTAINS(c.events, @event)',
parameters: [
{ name: '@productId', value: productId },
{ name: '@event', value: event },
],
})
.fetchAll();
return resources;
// Fetch enabled subscriptions and filter for array contains in-memory
const all = await subsCollection().findMany({
filter: { productId, enabled: true },
});
return all.filter(s => (s.events as string[]).includes(event));
}
// ── Delivery Log ────────────────────────────────────────────
export async function createDelivery(doc: WebhookDeliveryDoc): Promise<WebhookDeliveryDoc> {
const { resource } = await deliveries().items.create(doc);
return resource as WebhookDeliveryDoc;
return deliveriesCollection().create(doc);
}
export async function updateDelivery(doc: WebhookDeliveryDoc): Promise<WebhookDeliveryDoc> {
const { resource } = await deliveries().item(doc.id, doc.pk).replace(doc);
return resource as WebhookDeliveryDoc;
return deliveriesCollection().upsert(doc);
}
export async function listDeliveries(
@ -154,14 +143,9 @@ export async function listDeliveries(
options?: { limit?: number }
): Promise<WebhookDeliveryDoc[]> {
const limit = Math.min(options?.limit ?? 50, 200);
const { resources } = await deliveries()
.items.query<WebhookDeliveryDoc>({
query: 'SELECT TOP @limit * FROM c WHERE STARTSWITH(c.pk, @prefix) ORDER BY c.createdAt DESC',
parameters: [
{ name: '@limit', value: limit },
{ name: '@prefix', value: subscriptionId },
],
})
.fetchAll();
return resources;
return deliveriesCollection().findMany({
filter: { pk: { $startsWith: subscriptionId } },
sort: { createdAt: -1 },
limit,
});
}