Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
210 lines
6.7 KiB
TypeScript
210 lines
6.7 KiB
TypeScript
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
|
|
/**
|
|
* Guard for the handler contract (Review Focus 2): a domain event handler
|
|
* runs inside its delivery transaction and may be repeated after a crash,
|
|
* so it only writes to the database. Network work belongs to an outbox of
|
|
* its own (calendar sync, WebhooksService.enqueueDeliveries) that a worker
|
|
* loop drains. A static source scan:
|
|
* - every @OnDomainEvent handler lives in a `*.handlers.ts` file, and
|
|
* - a handler file imports no network client: no http/https/fetch library,
|
|
* no `fetch(` call, nothing from graph-client or webhook-delivery, and no
|
|
* src module that itself makes network calls (a fetch wrapper).
|
|
*/
|
|
const SRC = join(__dirname, '..');
|
|
const DECORATOR_FILE = join(
|
|
SRC,
|
|
'domain-events',
|
|
'on-domain-event.decorator.ts',
|
|
);
|
|
|
|
interface SourceFile {
|
|
path: string;
|
|
source: string;
|
|
}
|
|
|
|
const NETWORK_PACKAGES = new Set([
|
|
'http',
|
|
'https',
|
|
'http2',
|
|
'net',
|
|
'tls',
|
|
'dgram',
|
|
'undici',
|
|
'axios',
|
|
'node-fetch',
|
|
'cross-fetch',
|
|
'got',
|
|
'ky',
|
|
'superagent',
|
|
]);
|
|
const NETWORK_MODULE_NAME = /graph-client|webhook-delivery/;
|
|
const FETCH_CALL = /\bfetch\s*\(/;
|
|
const IMPORT_SPECIFIER =
|
|
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"]+)['"]/g;
|
|
|
|
function importsOf(source: string): string[] {
|
|
return [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1]);
|
|
}
|
|
|
|
function isNetworkPackage(specifier: string): boolean {
|
|
return NETWORK_PACKAGES.has(specifier.replace(/^node:/, ''));
|
|
}
|
|
|
|
/** A module that talks to the network itself. */
|
|
function makesNetworkCalls(file: SourceFile): boolean {
|
|
return (
|
|
FETCH_CALL.test(file.source) ||
|
|
importsOf(file.source).some(isNetworkPackage)
|
|
);
|
|
}
|
|
|
|
function resolveImport(from: string, specifier: string): string | undefined {
|
|
if (!specifier.startsWith('.')) {
|
|
return undefined;
|
|
}
|
|
const base = resolve(dirname(from), specifier);
|
|
return [`${base}.ts`, join(base, 'index.ts')].find(existsSync) ?? base;
|
|
}
|
|
|
|
/** Files declaring @OnDomainEvent handlers outside a `*.handlers.ts` file. */
|
|
function misplacedHandlerFiles(files: readonly SourceFile[]): string[] {
|
|
return files
|
|
.filter(
|
|
(file) =>
|
|
file.path !== DECORATOR_FILE &&
|
|
!file.path.endsWith('.spec.ts') &&
|
|
file.source.includes('@OnDomainEvent(') &&
|
|
!file.path.endsWith('.handlers.ts'),
|
|
)
|
|
.map((file) => file.path);
|
|
}
|
|
|
|
/** What a handler file must not use; empty when it keeps the contract. */
|
|
function networkViolations(
|
|
handlerFile: SourceFile,
|
|
networkModules: ReadonlySet<string>,
|
|
): string[] {
|
|
const violations = importsOf(handlerFile.source).filter((specifier) => {
|
|
const resolved = resolveImport(handlerFile.path, specifier);
|
|
return (
|
|
isNetworkPackage(specifier) ||
|
|
NETWORK_MODULE_NAME.test(specifier) ||
|
|
(resolved !== undefined && networkModules.has(resolved))
|
|
);
|
|
});
|
|
return FETCH_CALL.test(handlerFile.source)
|
|
? [...violations, 'fetch(']
|
|
: violations;
|
|
}
|
|
|
|
function sourceFiles(): SourceFile[] {
|
|
return readdirSync(SRC, { recursive: true, encoding: 'utf8' })
|
|
.filter((file) => file.endsWith('.ts'))
|
|
.map((file) => join(SRC, file))
|
|
.map((path) => ({ path, source: readFileSync(path, 'utf8') }));
|
|
}
|
|
|
|
function networkModulesOf(files: readonly SourceFile[]): Set<string> {
|
|
return new Set(
|
|
files
|
|
.filter((file) => !file.path.endsWith('.spec.ts'))
|
|
.filter(makesNetworkCalls)
|
|
.map((file) => file.path),
|
|
);
|
|
}
|
|
|
|
describe('domain event handler contract', () => {
|
|
const files = sourceFiles();
|
|
const networkModules = networkModulesOf(files);
|
|
|
|
it('every @OnDomainEvent handler lives in a *.handlers.ts file', () => {
|
|
expect(misplacedHandlerFiles(files)).toEqual([]);
|
|
});
|
|
|
|
it('handler files never import network clients', () => {
|
|
for (const file of files.filter((f) => f.path.endsWith('.handlers.ts'))) {
|
|
expect({
|
|
file: file.path,
|
|
violations: networkViolations(file, networkModules),
|
|
}).toEqual({
|
|
file: file.path,
|
|
violations: [],
|
|
});
|
|
}
|
|
});
|
|
|
|
it('knows the network modules of the code base (fetch wrappers included)', () => {
|
|
const relative = [...networkModules].map((path) =>
|
|
path
|
|
.slice(SRC.length + 1)
|
|
.split(sep)
|
|
.join('/'),
|
|
);
|
|
expect(relative).toEqual(
|
|
expect.arrayContaining([
|
|
'calendar-sync/graph-client.service.ts',
|
|
'webhooks/webhook-delivery.ts',
|
|
'weather/weather.service.ts',
|
|
'common/password/password-policy.ts',
|
|
]),
|
|
);
|
|
expect(relative).not.toContain('webhooks/webhooks.service.ts');
|
|
});
|
|
|
|
describe('with planted violations', () => {
|
|
const handlerPath = join(SRC, 'colonies', 'colonies.handlers.ts');
|
|
|
|
it('flags a handler declared outside a *.handlers.ts file', () => {
|
|
const planted = {
|
|
path: join(SRC, 'colonies', 'colonies.service.ts'),
|
|
source: "@OnDomainEvent('TaskDue', 'colonies.due')\n",
|
|
};
|
|
|
|
expect(misplacedHandlerFiles([planted])).toEqual([planted.path]);
|
|
expect(
|
|
misplacedHandlerFiles([
|
|
{ path: handlerPath, source: planted.source },
|
|
{
|
|
path: `${planted.path.slice(0, -3)}.spec.ts`,
|
|
source: planted.source,
|
|
},
|
|
{ path: DECORATOR_FILE, source: planted.source },
|
|
]),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
"import https from 'https';",
|
|
"import { request } from 'http';",
|
|
"import { get } from 'node:https';",
|
|
"const http = require('node:http');",
|
|
"import { fetch as undiciFetch } from 'undici';",
|
|
"import axios from 'axios';",
|
|
'const response = await fetch(url);',
|
|
"import { GraphClientService } from '../calendar-sync/graph-client.service';",
|
|
"import { postWebhook } from '../webhooks/webhook-delivery';",
|
|
"import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';",
|
|
"import { WeatherService } from '../weather/weather.service';",
|
|
"const { checkPassword } = await import('../common/password/password-policy');",
|
|
])('flags %s', (line) => {
|
|
expect(
|
|
networkViolations({ path: handlerPath, source: line }, networkModules),
|
|
).not.toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
"import { WebhooksService } from '../webhooks/webhooks.service';",
|
|
"import { Prisma } from '../../generated/prisma';",
|
|
"import type { DomainEventEnvelope } from '../domain-events/domain-event-types';",
|
|
'const prefetched = await tx.task.findMany();',
|
|
"const scheme = 'https';",
|
|
])('lets a database-only line pass: %s', (line) => {
|
|
expect(
|
|
networkViolations({ path: handlerPath, source: line }, networkModules),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
});
|