feat: platform backbone and field capture, first part (checkpoint)
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]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
@@ -0,0 +1,209 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user