Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 44 additions & 40 deletions packages/analytics/src/__tests__/analytics-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
// Check postgres availability at module load (top-level await)
const pgAvailable = await isPostgresAvailable();
const skipTests = getTestAdapter() === 'postgres' && !pgAvailable;
const ANALYTICS_HEAVY_TEST_TIMEOUT_MS = 15_000;

describe('AnalyticsEvent', () => {
describe('constructor', () => {
Expand Down Expand Up @@ -450,46 +451,49 @@ describe.skipIf(skipTests)(
expect(stats.trendPercent).toBe(200);
});

it('should report flat trend when change is within 5%', async () => {
const propertyId = 'prop-flat';
const now = new Date('2024-06-15T12:00:00Z');
const todayTs = new Date('2024-06-15T06:00:00Z');
const yesterdayTs = new Date('2024-06-14T06:00:00Z');

// 20 pageviews today
for (let i = 0; i < 20; i++) {
await (
await collection.create({
propertyId,
eventName: 'page_view',
clientId: `client-${i}`,
eventTimestamp: todayTs,
})
).save();
}

// 20 pageviews yesterday (0% change)
for (let i = 0; i < 20; i++) {
await (
await collection.create({
propertyId,
eventName: 'page_view',
clientId: `client-${i}`,
eventTimestamp: yesterdayTs,
})
).save();
}

const stats = await collection.getPropertyStatsWithTrend(
propertyId,
now,
);

expect(stats.todayPageviews).toBe(20);
expect(stats.yesterdayPageviews).toBe(20);
expect(stats.trend).toBe('flat');
expect(stats.trendPercent).toBe(0);
});
it(
'should report flat trend when change is within 5%',
async () => {
const propertyId = 'prop-flat';
const now = new Date('2024-06-15T12:00:00Z');
const todayTs = new Date('2024-06-15T06:00:00Z');
const yesterdayTs = new Date('2024-06-14T06:00:00Z');

// JSON-mode tests export on every write, so this case needs more headroom in CI.
for (let i = 0; i < 20; i++) {
await (
await collection.create({
propertyId,
eventName: 'page_view',
clientId: `client-${i}`,
eventTimestamp: todayTs,
})
).save();
}

for (let i = 0; i < 20; i++) {
await (
await collection.create({
propertyId,
eventName: 'page_view',
clientId: `client-${i}`,
eventTimestamp: yesterdayTs,
})
).save();
}

const stats = await collection.getPropertyStatsWithTrend(
propertyId,
now,
);

expect(stats.todayPageviews).toBe(20);
expect(stats.yesterdayPageviews).toBe(20);
expect(stats.trend).toBe('flat');
expect(stats.trendPercent).toBe(0);
},
ANALYTICS_HEAVY_TEST_TIMEOUT_MS,
);

it('should ignore non-pageview events', async () => {
const propertyId = 'prop-filter';
Expand Down
191 changes: 191 additions & 0 deletions packages/core/src/vite-plugin/sveltekit-generator.runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SmartObjectManifest } from '../scanner/types.js';
import { generateSvelteKitRoutes } from './sveltekit-generator.js';

function createManifest(
projectRoot: string,
objectFilePath: string,
): SmartObjectManifest {
return {
objects: {
Widget: {
className: 'Widget',
collection: 'widgets',
decoratorConfig: {
api: true,
},
fields: {},
filePath: objectFilePath,
methods: {},
},
},
packageName: '@test/generated-helper-app',
};
}

function writeWorkspaceCoreShim(projectRoot: string): void {
const packageDir = join(
projectRoot,
'node_modules',
'@happyvertical',
'smrt-core',
);
mkdirSync(packageDir, { recursive: true });
writeFileSync(
join(packageDir, 'package.json'),
JSON.stringify(
{
exports: {
'.': './index.js',
},
name: '@happyvertical/smrt-core',
type: 'module',
version: '1.0.0',
},
null,
2,
),
);
writeFileSync(
join(packageDir, 'index.js'),
[
'globalThis.__smrtGeneratedHelperCalls ??= [];',
'globalThis.__smrtGeneratedHelperRegistrations ??= [];',
'export const ObjectRegistry = {',
' async getCollection(...args) {',
' globalThis.__smrtGeneratedHelperCalls.push(args);',
" return { marker: 'collection' };",
' },',
' register(...args) {',
' globalThis.__smrtGeneratedHelperRegistrations.push(args);',
' },',
'};',
].join('\n'),
);
}

describe('generated SvelteKit helper runtime', () => {
const originalScopedDbGetter = globalThis.__smrtGetRequestScopedDatabase;
let projectRoot = '';

beforeEach(() => {
(globalThis as Record<string, unknown>).__smrtGeneratedHelperCalls = [];
(globalThis as Record<string, unknown>).__smrtGeneratedHelperRegistrations =
[];
projectRoot = mkdtempSync(join(tmpdir(), 'smrt-sveltekit-helper-'));
mkdirSync(join(projectRoot, 'src/lib/objects'), {
recursive: true,
});
writeFileSync(
join(projectRoot, 'package.json'),
JSON.stringify(
{
name: 'generated-helper-app',
type: 'module',
version: '1.0.0',
},
null,
2,
),
);
writeFileSync(
join(projectRoot, 'src/lib/objects/Widget.ts'),
'export class Widget {}\n',
);
writeWorkspaceCoreShim(projectRoot);
});

afterEach(() => {
vi.restoreAllMocks();

if (originalScopedDbGetter) {
globalThis.__smrtGetRequestScopedDatabase = originalScopedDbGetter;
} else {
delete globalThis.__smrtGetRequestScopedDatabase;
}

rmSync(projectRoot, { force: true, recursive: true });
projectRoot = '';
delete (globalThis as Record<string, unknown>).__smrtGeneratedHelperCalls;
delete (globalThis as Record<string, unknown>)
.__smrtGeneratedHelperRegistrations;
});

async function importGeneratedHelper() {
const objectFilePath = join(projectRoot, 'src/lib/objects/Widget.ts');
await generateSvelteKitRoutes(
projectRoot,
createManifest(projectRoot, objectFilePath),
{
configFileName: 'smrt.ts',
configPath: 'src/lib/server',
enabled: true,
objectsDir: 'src/lib/objects',
routesDir: 'src/routes/api',
},
);

return await import(
pathToFileURL(join(projectRoot, 'src/lib/server/smrt.ts')).href
);
}

it('prefers the request-scoped database when no explicit db override is provided', async () => {
const requestScopedDb = {
type: 'sqlite',
url: 'request-scoped.db',
};
globalThis.__smrtGetRequestScopedDatabase = () => requestScopedDb as never;

const helper = await importGeneratedHelper();

await helper.getCollection('Widget');

expect(
(globalThis as Record<string, unknown>).__smrtGeneratedHelperCalls,
).toEqual([
[
'Widget',
expect.objectContaining({
db: requestScopedDb,
}),
],
]);
});

it('keeps explicit db overrides ahead of the request-scoped database', async () => {
const requestScopedDb = {
type: 'sqlite',
url: 'request-scoped.db',
};
const explicitDb = {
type: 'sqlite',
url: 'explicit.db',
};
globalThis.__smrtGetRequestScopedDatabase = () => requestScopedDb as never;

const helper = await importGeneratedHelper();

await helper.getCollection('Widget', {
db: explicitDb,
});

expect(
(globalThis as Record<string, unknown>).__smrtGeneratedHelperCalls,
).toEqual([
[
'Widget',
expect.objectContaining({
db: expect.objectContaining({
type: 'sqlite',
url: 'explicit.db',
}),
}),
],
]);
});
});
2 changes: 2 additions & 0 deletions packages/core/src/vite-plugin/sveltekit-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ describe('SvelteKit Route Generator', () => {

expect(configContent).toContain('objectOverrides');
expect(configContent).toContain('getDefaultConfig');
expect(configContent).toContain('getRequestScopedDatabase');
expect(configContent).toContain('getSmrtConfig');
expect(configContent).toContain('getCollection');
expect(configContent).toContain('import { ObjectRegistry }');
Expand All @@ -111,6 +112,7 @@ describe('SvelteKit Route Generator', () => {
expect(configContent).toContain(
'{ ...(defaults.db as any), ...(override.db as any) }',
);
expect(configContent).toContain('requestScopedDb ?? config.db');
});

it('should skip config generation when file already exists', async () => {
Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/vite-plugin/sveltekit-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,13 @@ import './smrt-register.js';
import { ObjectRegistry } from '@happyvertical/smrt-core';
import type { SmrtClassOptions } from '@happyvertical/smrt-core';

declare global {
// eslint-disable-next-line no-var
var __smrtGetRequestScopedDatabase:
| (() => SmrtClassOptions['db'] | undefined)
| undefined;
}

/**
* Per-object configuration overrides
* Define specific backends for objects that differ from project defaults
Expand Down Expand Up @@ -697,6 +704,11 @@ function getDefaultConfig(): SmrtClassOptions {
};
}

function getRequestScopedDatabase(): SmrtClassOptions['db'] | undefined {
const getter = globalThis.__smrtGetRequestScopedDatabase;
return typeof getter === 'function' ? getter() : undefined;
}

/**
* Get configuration for a specific SMRT object
* Merges project defaults with per-object overrides if defined
Expand Down Expand Up @@ -729,6 +741,11 @@ export async function getCollection<
T extends import('@happyvertical/smrt-core').SmrtObject,
>(className: string, overrides: Partial<SmrtClassOptions> = {}) {
const config = getSmrtConfig(className);
const objectOverride = objectOverrides[className];
const requestScopedDb =
!overrides.db && !objectOverride?.db
? getRequestScopedDatabase()
: undefined;

return await ObjectRegistry.getCollection<T>(
className,
Expand All @@ -737,7 +754,7 @@ export async function getCollection<
...overrides,
db: overrides.db
? { ...(config.db as any), ...(overrides.db as any) }
: config.db,
: requestScopedDb ?? config.db,
ai: overrides.ai !== undefined ? overrides.ai : config.ai
}
);
Expand Down
6 changes: 4 additions & 2 deletions packages/images/src/__tests__/image-adapter-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ const adapterConfigs = [
},
];

const ADAPTER_HOOK_TIMEOUT_MS = 30_000;

// ============================================================================
// Reusable Test Suites
// ============================================================================
Expand Down Expand Up @@ -260,14 +262,14 @@ describe('Image Adapter Parity', () => {
db = await getTestDatabase(dbConfig);
images = await ImageCollection.create({ db });
assets = await AssetCollection.create({ db });
});
}, ADAPTER_HOOK_TIMEOUT_MS);

afterEach(async () => {
if (db && typeof db.close === 'function') {
await db.close();
}
await adapterConfig.cleanup(dbConfig);
});
}, ADAPTER_HOOK_TIMEOUT_MS);

const getImageContext = () => ({ images });
const getFullContext = () => ({ images, assets });
Expand Down
Loading
Loading