create-agent-rig 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/package.json +54 -0
- package/packages/cli/dist/commands/create.js +109 -0
- package/packages/cli/dist/index.js +102 -0
- package/packages/cli/dist/lib/colors.js +14 -0
- package/packages/cli/dist/lib/composition.js +20 -0
- package/packages/cli/dist/lib/copy-tree.js +91 -0
- package/packages/cli/dist/lib/prompts.js +24 -0
- package/packages/cli/dist/lib/substitute.js +22 -0
- package/packages/cli/dist/lib/summary.js +41 -0
- package/packages/cli/dist/lib/targets.js +14 -0
- package/packages/cli/dist/templates.js +21 -0
- package/scripts/prepare.mjs +29 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +49 -0
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +59 -0
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +51 -0
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +39 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +36 -0
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
- package/templates/agent-os/universal/.claude/agents/test-writer.md +36 -0
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +43 -0
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +79 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +53 -0
- package/templates/agent-os/universal/.claude/rules/architecture.md +74 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +81 -0
- package/templates/agent-os/universal/.claude/rules/workflow.md +62 -0
- package/templates/agent-os/universal/.claude/settings.json +28 -0
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +53 -0
- package/templates/agent-os/universal/CLAUDE.md +62 -0
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +23 -0
- package/templates/skeleton/aws-serverless/README.md +78 -0
- package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +17 -0
- package/templates/skeleton/aws-serverless/apps/web/package.json +19 -0
- package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +17 -0
- package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +96 -0
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +29 -0
- package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +23 -0
- package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +38 -0
- package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +14 -0
- package/templates/skeleton/aws-serverless/eslint.config.mjs +20 -0
- package/templates/skeleton/aws-serverless/gitignore +9 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +19 -0
- package/templates/skeleton/aws-serverless/infra/cdk.json +3 -0
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +116 -0
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +32 -0
- package/templates/skeleton/aws-serverless/infra/package.json +18 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +104 -0
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +41 -0
- package/templates/skeleton/aws-serverless/package.json +30 -0
- package/templates/skeleton/aws-serverless/packages/core/package.json +11 -0
- package/templates/skeleton/aws-serverless/packages/core/src/events.ts +14 -0
- package/templates/skeleton/aws-serverless/packages/core/src/index.ts +15 -0
- package/templates/skeleton/aws-serverless/packages/core/src/note.ts +69 -0
- package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +23 -0
- package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +101 -0
- package/templates/skeleton/aws-serverless/packages/db/package.json +14 -0
- package/templates/skeleton/aws-serverless/packages/db/src/client.ts +17 -0
- package/templates/skeleton/aws-serverless/packages/db/src/index.ts +2 -0
- package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +52 -0
- package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +91 -0
- package/templates/skeleton/aws-serverless/packages/shared/package.json +11 -0
- package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +17 -0
- package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +33 -0
- package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +3 -0
- package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +20 -0
- package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +26 -0
- package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +28 -0
- package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +19 -0
- package/templates/skeleton/aws-serverless/pnpm-lock.yaml +2855 -0
- package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +14 -0
- package/templates/skeleton/aws-serverless/services/api/package.json +15 -0
- package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +26 -0
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +42 -0
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +24 -0
- package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +12 -0
- package/templates/skeleton/aws-serverless/services/api/src/main.ts +21 -0
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +30 -0
- package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +14 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +92 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +45 -0
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +51 -0
- package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +22 -0
- package/templates/skeleton/aws-serverless/services/worker/package.json +12 -0
- package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +15 -0
- package/templates/skeleton/aws-serverless/services/worker/src/main.ts +7 -0
- package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +37 -0
- package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +61 -0
- package/templates/skeleton/aws-serverless/tsconfig.base.json +15 -0
- package/templates/skeleton/aws-serverless/tsconfig.json +16 -0
- package/templates/skeleton/aws-serverless/vitest.config.ts +14 -0
- package/templates/skeleton/node-service/.github/workflows/ci.yml +22 -0
- package/templates/skeleton/node-service/README.md +74 -0
- package/templates/skeleton/node-service/apps/web/next.config.mjs +17 -0
- package/templates/skeleton/node-service/apps/web/package.json +19 -0
- package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +17 -0
- package/templates/skeleton/node-service/apps/web/src/app/page.tsx +96 -0
- package/templates/skeleton/node-service/apps/web/src/lib/api.ts +29 -0
- package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +23 -0
- package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +38 -0
- package/templates/skeleton/node-service/apps/web/tsconfig.json +14 -0
- package/templates/skeleton/node-service/eslint.config.mjs +20 -0
- package/templates/skeleton/node-service/gitignore +9 -0
- package/templates/skeleton/node-service/package.json +28 -0
- package/templates/skeleton/node-service/packages/core/package.json +11 -0
- package/templates/skeleton/node-service/packages/core/src/events.ts +14 -0
- package/templates/skeleton/node-service/packages/core/src/index.ts +15 -0
- package/templates/skeleton/node-service/packages/core/src/note.ts +69 -0
- package/templates/skeleton/node-service/packages/core/test/events.test.ts +23 -0
- package/templates/skeleton/node-service/packages/core/test/note.test.ts +101 -0
- package/templates/skeleton/node-service/packages/db/package.json +12 -0
- package/templates/skeleton/node-service/packages/db/src/index.ts +1 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +63 -0
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +80 -0
- package/templates/skeleton/node-service/packages/shared/package.json +11 -0
- package/templates/skeleton/node-service/packages/shared/src/env.ts +17 -0
- package/templates/skeleton/node-service/packages/shared/src/errors.ts +33 -0
- package/templates/skeleton/node-service/packages/shared/src/index.ts +3 -0
- package/templates/skeleton/node-service/packages/shared/src/logger.ts +20 -0
- package/templates/skeleton/node-service/packages/shared/test/env.test.ts +26 -0
- package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +28 -0
- package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +19 -0
- package/templates/skeleton/node-service/pnpm-lock.yaml +2399 -0
- package/templates/skeleton/node-service/pnpm-workspace.yaml +13 -0
- package/templates/skeleton/node-service/services/api/package.json +17 -0
- package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +23 -0
- package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +40 -0
- package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +23 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +47 -0
- package/templates/skeleton/node-service/services/api/src/server.ts +89 -0
- package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +30 -0
- package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +14 -0
- package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +64 -0
- package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +43 -0
- package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +48 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +123 -0
- package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +32 -0
- package/templates/skeleton/node-service/services/worker/package.json +16 -0
- package/templates/skeleton/node-service/services/worker/src/main.ts +28 -0
- package/templates/skeleton/node-service/services/worker/src/spool.ts +60 -0
- package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +38 -0
- package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +34 -0
- package/templates/skeleton/node-service/services/worker/test/spool.test.ts +76 -0
- package/templates/skeleton/node-service/tsconfig.base.json +15 -0
- package/templates/skeleton/node-service/tsconfig.json +13 -0
- package/templates/skeleton/node-service/vitest.config.ts +12 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
packages:
|
|
2
|
+
- packages/*
|
|
3
|
+
- services/*
|
|
4
|
+
- apps/*
|
|
5
|
+
- infra
|
|
6
|
+
|
|
7
|
+
# esbuild's postinstall is intentionally NOT approved: skipping it keeps the JS
|
|
8
|
+
# launcher in place (which resolves the platform binary itself); approving it
|
|
9
|
+
# swaps in a raw binary that pnpm's node-based bin shim cannot execute.
|
|
10
|
+
# sharp (via next) only matters for the image optimizer, which a static
|
|
11
|
+
# export never runs — its native build stays off.
|
|
12
|
+
allowBuilds:
|
|
13
|
+
esbuild: false
|
|
14
|
+
sharp: false
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@app/api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/main.ts",
|
|
7
|
+
"types": "src/main.ts",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@app/core": "workspace:*",
|
|
10
|
+
"@app/db": "workspace:*",
|
|
11
|
+
"@app/shared": "workspace:*",
|
|
12
|
+
"@aws-sdk/client-sqs": "^3.1092.0",
|
|
13
|
+
"zod": "^4.4.3"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The one place that touches the queue SDK on the sending side.
|
|
2
|
+
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
|
|
3
|
+
import type { NoteCreatedEvent } from '@app/core';
|
|
4
|
+
import type { EventPublisher } from '../usecases/create-note.js';
|
|
5
|
+
|
|
6
|
+
/** Structural view of the SQS client — tests stub it. */
|
|
7
|
+
export interface QueueClient {
|
|
8
|
+
send(command: unknown): Promise<unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createQueueClient(): QueueClient {
|
|
12
|
+
return new SQSClient({}) as QueueClient;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class SqsEventPublisher implements EventPublisher {
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly client: QueueClient,
|
|
18
|
+
private readonly queueUrl: string,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
async publish(event: NoteCreatedEvent): Promise<void> {
|
|
22
|
+
await this.client.send(
|
|
23
|
+
new SendMessageCommand({ QueueUrl: this.queueUrl, MessageBody: JSON.stringify(event) }),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The handler translates transport ⇄ domain and nothing else: decode the
|
|
2
|
+
// payload, call the usecase, map the result (or the typed error) to HTTP.
|
|
3
|
+
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
|
|
4
|
+
import { InvalidNoteError } from '@app/core';
|
|
5
|
+
import { AppError } from '@app/shared';
|
|
6
|
+
import { createNoteUsecase, type CreateNoteDeps } from '../usecases/create-note.js';
|
|
7
|
+
|
|
8
|
+
type Handler = (event: APIGatewayProxyEventV2) => Promise<APIGatewayProxyResultV2>;
|
|
9
|
+
|
|
10
|
+
const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
|
|
11
|
+
statusCode,
|
|
12
|
+
headers: { 'content-type': 'application/json' },
|
|
13
|
+
body: JSON.stringify(body),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export function makeCreateNoteHandler(deps: CreateNoteDeps): Handler {
|
|
17
|
+
return async (event) => {
|
|
18
|
+
let payload: unknown;
|
|
19
|
+
try {
|
|
20
|
+
const raw = event.isBase64Encoded
|
|
21
|
+
? Buffer.from(event.body ?? '', 'base64').toString('utf8')
|
|
22
|
+
: (event.body ?? '');
|
|
23
|
+
payload = JSON.parse(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
return json(400, { error: 'body must be valid JSON' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const note = await createNoteUsecase(payload, deps);
|
|
30
|
+
return json(201, { note });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error instanceof InvalidNoteError) {
|
|
33
|
+
return json(400, { error: 'invalid note', issues: error.issues });
|
|
34
|
+
}
|
|
35
|
+
if (error instanceof AppError) {
|
|
36
|
+
return json(error.statusCode, { error: error.message, code: error.code });
|
|
37
|
+
}
|
|
38
|
+
deps.log.error('unhandled error in create-note', { error: String(error) });
|
|
39
|
+
return json(500, { error: 'internal error' });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { APIGatewayProxyResultV2 } from 'aws-lambda';
|
|
2
|
+
import { AppError } from '@app/shared';
|
|
3
|
+
import type { Logger } from '@app/shared';
|
|
4
|
+
import { listNotesUsecase, type ListNotesDeps } from '../usecases/list-notes.js';
|
|
5
|
+
|
|
6
|
+
const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
|
|
7
|
+
statusCode,
|
|
8
|
+
headers: { 'content-type': 'application/json' },
|
|
9
|
+
body: JSON.stringify(body),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function makeListNotesHandler(deps: ListNotesDeps & { log: Logger }) {
|
|
13
|
+
return async (): Promise<APIGatewayProxyResultV2> => {
|
|
14
|
+
try {
|
|
15
|
+
return json(200, { notes: await listNotesUsecase(deps) });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error instanceof AppError) {
|
|
18
|
+
return json(error.statusCode, { error: error.message, code: error.code });
|
|
19
|
+
}
|
|
20
|
+
deps.log.error('unhandled error in list-notes', { error: String(error) });
|
|
21
|
+
return json(500, { error: 'internal error' });
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Composition root for the list function — one function, one entry, one job.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { NoteModel, createDocumentClient } from '@app/db';
|
|
4
|
+
import { createLogger, loadEnv } from '@app/shared';
|
|
5
|
+
import { makeListNotesHandler } from './handlers/list-notes.js';
|
|
6
|
+
|
|
7
|
+
const env = loadEnv(z.object({ TABLE_NAME: z.string().min(1) }), process.env);
|
|
8
|
+
|
|
9
|
+
export const handler = makeListNotesHandler({
|
|
10
|
+
notes: new NoteModel(createDocumentClient(), env.TABLE_NAME),
|
|
11
|
+
log: createLogger({ service: 'api', handler: 'list-notes' }),
|
|
12
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Composition root — the ONLY file with real wiring. Tests target the factory
|
|
2
|
+
// (makeCreateNoteHandler) with stub deps and never import this module.
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { NoteModel, createDocumentClient } from '@app/db';
|
|
6
|
+
import { createLogger, loadEnv } from '@app/shared';
|
|
7
|
+
import { SqsEventPublisher, createQueueClient } from './adapters/sqs-publisher.js';
|
|
8
|
+
import { makeCreateNoteHandler } from './handlers/create-note.js';
|
|
9
|
+
|
|
10
|
+
const env = loadEnv(
|
|
11
|
+
z.object({ TABLE_NAME: z.string().min(1), QUEUE_URL: z.string().min(1) }),
|
|
12
|
+
process.env,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
export const handler = makeCreateNoteHandler({
|
|
16
|
+
notes: new NoteModel(createDocumentClient(), env.TABLE_NAME),
|
|
17
|
+
events: new SqsEventPublisher(createQueueClient(), env.QUEUE_URL),
|
|
18
|
+
newId: () => randomUUID(),
|
|
19
|
+
now: () => new Date().toISOString(),
|
|
20
|
+
log: createLogger({ service: 'api' }),
|
|
21
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The usecase layer is mandatory: every business operation lives here, and
|
|
2
|
+
// handlers call nothing below it. Dependencies are consumer-owned interfaces —
|
|
3
|
+
// the usecase says what it needs, adapters satisfy it.
|
|
4
|
+
import { createNote, makeNoteCreatedEvent, type Note, type NoteCreatedEvent } from '@app/core';
|
|
5
|
+
import type { Logger } from '@app/shared';
|
|
6
|
+
|
|
7
|
+
export interface NoteStore {
|
|
8
|
+
put(note: Note): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface EventPublisher {
|
|
12
|
+
publish(event: NoteCreatedEvent): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CreateNoteDeps {
|
|
16
|
+
notes: NoteStore;
|
|
17
|
+
events: EventPublisher;
|
|
18
|
+
/** Id generation and the clock are decided here, never inside the core. */
|
|
19
|
+
newId(): string;
|
|
20
|
+
now(): string;
|
|
21
|
+
log: Logger;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function createNoteUsecase(input: unknown, deps: CreateNoteDeps): Promise<Note> {
|
|
25
|
+
const note = createNote(input, { id: deps.newId(), createdAt: deps.now() });
|
|
26
|
+
await deps.notes.put(note);
|
|
27
|
+
await deps.events.publish(makeNoteCreatedEvent(note));
|
|
28
|
+
deps.log.info('note created', { noteId: note.id, slug: note.slug });
|
|
29
|
+
return note;
|
|
30
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Even a trivial read goes through a usecase — the uniformity is the rule.
|
|
2
|
+
import type { Note } from '@app/core';
|
|
3
|
+
|
|
4
|
+
export interface NoteLister {
|
|
5
|
+
list(): Promise<Note[]>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ListNotesDeps {
|
|
9
|
+
notes: NoteLister;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function listNotesUsecase(deps: ListNotesDeps): Promise<Note[]> {
|
|
13
|
+
return deps.notes.list();
|
|
14
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
|
|
2
|
+
import { AppError, createLogger } from '@app/shared';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { makeCreateNoteHandler } from '../src/handlers/create-note.js';
|
|
5
|
+
import type { CreateNoteDeps } from '../src/usecases/create-note.js';
|
|
6
|
+
|
|
7
|
+
const event = (body: string): APIGatewayProxyEventV2 =>
|
|
8
|
+
({ body, isBase64Encoded: false }) as APIGatewayProxyEventV2;
|
|
9
|
+
|
|
10
|
+
function stubDeps(overrides: Partial<CreateNoteDeps> = {}) {
|
|
11
|
+
const deps: CreateNoteDeps = {
|
|
12
|
+
notes: { put: async () => {} },
|
|
13
|
+
events: { publish: async () => {} },
|
|
14
|
+
newId: () => 'fixed-id',
|
|
15
|
+
now: () => '2024-01-01T00:00:00.000Z',
|
|
16
|
+
log: createLogger({}, () => {}),
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
return deps;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const asResult = (r: unknown) => r as APIGatewayProxyStructuredResultV2;
|
|
23
|
+
|
|
24
|
+
describe('POST /notes handler', () => {
|
|
25
|
+
it('returns 201 with the created note', async () => {
|
|
26
|
+
const handler = makeCreateNoteHandler(stubDeps());
|
|
27
|
+
const result = asResult(await handler(event(JSON.stringify({ title: 'Hello' }))));
|
|
28
|
+
expect(result.statusCode).toBe(201);
|
|
29
|
+
expect(JSON.parse(result.body!)).toMatchObject({
|
|
30
|
+
note: { id: 'fixed-id', slug: 'hello', createdAt: '2024-01-01T00:00:00.000Z' },
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('decodes base64-encoded bodies', async () => {
|
|
35
|
+
const handler = makeCreateNoteHandler(stubDeps());
|
|
36
|
+
const raw = Buffer.from(JSON.stringify({ title: 'Encoded' })).toString('base64');
|
|
37
|
+
const result = asResult(
|
|
38
|
+
await handler({ body: raw, isBase64Encoded: true } as APIGatewayProxyEventV2),
|
|
39
|
+
);
|
|
40
|
+
expect(result.statusCode).toBe(201);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns 400 for malformed JSON without touching the store', async () => {
|
|
44
|
+
let touched = false;
|
|
45
|
+
const handler = makeCreateNoteHandler(
|
|
46
|
+
stubDeps({
|
|
47
|
+
notes: {
|
|
48
|
+
put: async () => {
|
|
49
|
+
touched = true;
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
const result = asResult(await handler(event('{not json')));
|
|
55
|
+
expect(result.statusCode).toBe(400);
|
|
56
|
+
expect(touched).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns 400 with issues for invalid input', async () => {
|
|
60
|
+
const handler = makeCreateNoteHandler(stubDeps());
|
|
61
|
+
const result = asResult(await handler(event(JSON.stringify({ title: '' }))));
|
|
62
|
+
expect(result.statusCode).toBe(400);
|
|
63
|
+
expect(JSON.parse(result.body!).issues.join(' ')).toMatch(/title/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('maps typed errors to their status code', async () => {
|
|
67
|
+
const handler = makeCreateNoteHandler(
|
|
68
|
+
stubDeps({
|
|
69
|
+
notes: {
|
|
70
|
+
put: () => Promise.reject(new AppError('conflict', { statusCode: 409, code: 'DUP' })),
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
const result = asResult(await handler(event(JSON.stringify({ title: 'T' }))));
|
|
75
|
+
expect(result.statusCode).toBe(409);
|
|
76
|
+
expect(JSON.parse(result.body!)).toEqual({ error: 'conflict', code: 'DUP' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('hides unknown errors behind a 500 and logs them', async () => {
|
|
80
|
+
const logs: string[] = [];
|
|
81
|
+
const handler = makeCreateNoteHandler(
|
|
82
|
+
stubDeps({
|
|
83
|
+
notes: { put: () => Promise.reject(new Error('secret detail')) },
|
|
84
|
+
log: createLogger({}, (line) => logs.push(line)),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
const result = asResult(await handler(event(JSON.stringify({ title: 'T' }))));
|
|
88
|
+
expect(result.statusCode).toBe(500);
|
|
89
|
+
expect(result.body).not.toContain('secret detail');
|
|
90
|
+
expect(logs.join('\n')).toContain('secret detail');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { InvalidNoteError, type Note, type NoteCreatedEvent } from '@app/core';
|
|
2
|
+
import { createLogger } from '@app/shared';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { createNoteUsecase, type CreateNoteDeps } from '../src/usecases/create-note.js';
|
|
5
|
+
|
|
6
|
+
function stubDeps() {
|
|
7
|
+
const stored: Note[] = [];
|
|
8
|
+
const published: NoteCreatedEvent[] = [];
|
|
9
|
+
const logs: string[] = [];
|
|
10
|
+
const deps: CreateNoteDeps = {
|
|
11
|
+
notes: { put: async (note) => void stored.push(note) },
|
|
12
|
+
events: { publish: async (event) => void published.push(event) },
|
|
13
|
+
newId: () => 'fixed-id',
|
|
14
|
+
now: () => '2024-01-01T00:00:00.000Z',
|
|
15
|
+
log: createLogger({}, (line) => logs.push(line)),
|
|
16
|
+
};
|
|
17
|
+
return { deps, stored, published, logs };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('createNoteUsecase', () => {
|
|
21
|
+
it('creates, stores, publishes, and logs — in that order of effects', async () => {
|
|
22
|
+
const { deps, stored, published, logs } = stubDeps();
|
|
23
|
+
const note = await createNoteUsecase({ title: 'Hello World' }, deps);
|
|
24
|
+
expect(note).toMatchObject({ id: 'fixed-id', slug: 'hello-world' });
|
|
25
|
+
expect(stored).toEqual([note]);
|
|
26
|
+
expect(published).toEqual([{ type: 'note.created', note }]);
|
|
27
|
+
expect(logs.join('\n')).toContain('fixed-id');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects invalid input before any side effect', async () => {
|
|
31
|
+
const { deps, stored, published } = stubDeps();
|
|
32
|
+
await expect(createNoteUsecase({ title: '' }, deps)).rejects.toThrow(InvalidNoteError);
|
|
33
|
+
expect(stored).toEqual([]);
|
|
34
|
+
expect(published).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('propagates store failures and does not publish', async () => {
|
|
38
|
+
const { deps, published } = stubDeps();
|
|
39
|
+
deps.notes = {
|
|
40
|
+
put: () => Promise.reject(new Error('dynamo down')),
|
|
41
|
+
};
|
|
42
|
+
await expect(createNoteUsecase({ title: 'T' }, deps)).rejects.toThrow('dynamo down');
|
|
43
|
+
expect(published).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
|
|
2
|
+
import { AppError, createLogger } from '@app/shared';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { makeListNotesHandler } from '../src/handlers/list-notes.js';
|
|
5
|
+
import { listNotesUsecase } from '../src/usecases/list-notes.js';
|
|
6
|
+
|
|
7
|
+
const note = {
|
|
8
|
+
id: 'n1',
|
|
9
|
+
title: 'Hello',
|
|
10
|
+
slug: 'hello',
|
|
11
|
+
tags: [],
|
|
12
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const asResult = (r: unknown) => r as APIGatewayProxyStructuredResultV2;
|
|
16
|
+
|
|
17
|
+
describe('listNotesUsecase', () => {
|
|
18
|
+
it('returns what the model lists', async () => {
|
|
19
|
+
expect(await listNotesUsecase({ notes: { list: async () => [note] } })).toEqual([note]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('GET /notes handler', () => {
|
|
24
|
+
it('returns 200 with the notes', async () => {
|
|
25
|
+
const handler = makeListNotesHandler({
|
|
26
|
+
notes: { list: async () => [note] },
|
|
27
|
+
log: createLogger({}, () => {}),
|
|
28
|
+
});
|
|
29
|
+
const result = asResult(await handler());
|
|
30
|
+
expect(result.statusCode).toBe(200);
|
|
31
|
+
expect(JSON.parse(result.body!)).toEqual({ notes: [note] });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('maps typed errors and hides unknown ones', async () => {
|
|
35
|
+
const typed = makeListNotesHandler({
|
|
36
|
+
notes: { list: () => Promise.reject(new AppError('nope', { statusCode: 503, code: 'X' })) },
|
|
37
|
+
log: createLogger({}, () => {}),
|
|
38
|
+
});
|
|
39
|
+
expect(asResult(await typed()).statusCode).toBe(503);
|
|
40
|
+
|
|
41
|
+
const logs: string[] = [];
|
|
42
|
+
const unknown = makeListNotesHandler({
|
|
43
|
+
notes: { list: () => Promise.reject(new Error('secret')) },
|
|
44
|
+
log: createLogger({}, (line) => logs.push(line)),
|
|
45
|
+
});
|
|
46
|
+
const result = asResult(await unknown());
|
|
47
|
+
expect(result.statusCode).toBe(500);
|
|
48
|
+
expect(result.body).not.toContain('secret');
|
|
49
|
+
expect(logs.join('')).toContain('secret');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SendMessageCommand } from '@aws-sdk/client-sqs';
|
|
2
|
+
import { makeNoteCreatedEvent, createNote } from '@app/core';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { SqsEventPublisher } from '../src/adapters/sqs-publisher.js';
|
|
5
|
+
|
|
6
|
+
describe('SqsEventPublisher', () => {
|
|
7
|
+
it('sends the JSON-encoded event to the configured queue', async () => {
|
|
8
|
+
const sent: unknown[] = [];
|
|
9
|
+
const publisher = new SqsEventPublisher(
|
|
10
|
+
{ send: (c) => (sent.push(c), Promise.resolve({})) },
|
|
11
|
+
'https://queue.example/q',
|
|
12
|
+
);
|
|
13
|
+
const note = createNote({ title: 'Hi' }, { id: 'n1', createdAt: '2024-01-01T00:00:00.000Z' });
|
|
14
|
+
const event = makeNoteCreatedEvent(note);
|
|
15
|
+
await publisher.publish(event);
|
|
16
|
+
|
|
17
|
+
const command = sent[0] as SendMessageCommand;
|
|
18
|
+
expect(command).toBeInstanceOf(SendMessageCommand);
|
|
19
|
+
expect(command.input.QueueUrl).toBe('https://queue.example/q');
|
|
20
|
+
expect(JSON.parse(command.input.MessageBody!)).toEqual(event);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SQSEvent } from 'aws-lambda';
|
|
2
|
+
import type { ProcessDeps } from '../usecases/process-note-created.js';
|
|
3
|
+
import { processNoteCreated } from '../usecases/process-note-created.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The event source uses batchSize 1, so a throw fails exactly this message and
|
|
7
|
+
* SQS redrives it (then the DLQ takes over). Keep the handler a thin loop.
|
|
8
|
+
*/
|
|
9
|
+
export function makeNoteCreatedHandler(deps: ProcessDeps) {
|
|
10
|
+
return async (event: SQSEvent): Promise<void> => {
|
|
11
|
+
for (const record of event.Records) {
|
|
12
|
+
await processNoteCreated(record.body, deps);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Queue discipline: validate the message against the shared event schema; a
|
|
2
|
+
// message that does not parse is poison — throw, let SQS retry it, and after
|
|
3
|
+
// maxReceiveCount the DLQ (with its alarm) catches it. Never swallow poison.
|
|
4
|
+
import { NoteCreatedEventSchema, type NoteCreatedEvent } from '@app/core';
|
|
5
|
+
import { ValidationError, type Logger } from '@app/shared';
|
|
6
|
+
|
|
7
|
+
export interface ProcessDeps {
|
|
8
|
+
log: Logger;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseNoteCreatedEvent(raw: string): NoteCreatedEvent {
|
|
12
|
+
let candidate: unknown;
|
|
13
|
+
try {
|
|
14
|
+
candidate = JSON.parse(raw);
|
|
15
|
+
} catch {
|
|
16
|
+
throw new ValidationError('message body is not JSON');
|
|
17
|
+
}
|
|
18
|
+
const parsed = NoteCreatedEventSchema.safeParse(candidate);
|
|
19
|
+
if (!parsed.success) {
|
|
20
|
+
throw new ValidationError(
|
|
21
|
+
'message is not a note.created event',
|
|
22
|
+
parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return parsed.data;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function processNoteCreated(raw: string, deps: ProcessDeps): Promise<void> {
|
|
29
|
+
const event = parseNoteCreatedEvent(raw);
|
|
30
|
+
// The skeleton's "processing" is deliberately small: acknowledge the note.
|
|
31
|
+
// Replace this with real downstream work; keep the validate-first shape.
|
|
32
|
+
deps.log.info('note.created processed', {
|
|
33
|
+
noteId: event.note.id,
|
|
34
|
+
slug: event.note.slug,
|
|
35
|
+
tagCount: event.note.tags.length,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { SQSEvent } from 'aws-lambda';
|
|
2
|
+
import { createNote, makeNoteCreatedEvent } from '@app/core';
|
|
3
|
+
import { ValidationError, createLogger } from '@app/shared';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { makeNoteCreatedHandler } from '../src/handlers/note-created.js';
|
|
6
|
+
import { parseNoteCreatedEvent, processNoteCreated } from '../src/usecases/process-note-created.js';
|
|
7
|
+
|
|
8
|
+
const note = createNote({ title: 'Hello' }, { id: 'n1', createdAt: '2024-01-01T00:00:00.000Z' });
|
|
9
|
+
const validBody = JSON.stringify(makeNoteCreatedEvent(note));
|
|
10
|
+
|
|
11
|
+
const sqsEvent = (...bodies: string[]): SQSEvent =>
|
|
12
|
+
({ Records: bodies.map((body) => ({ body })) }) as SQSEvent;
|
|
13
|
+
|
|
14
|
+
function collectLogs() {
|
|
15
|
+
const lines: string[] = [];
|
|
16
|
+
return { log: createLogger({}, (line) => lines.push(line)), lines };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('parseNoteCreatedEvent', () => {
|
|
20
|
+
it('parses a valid event', () => {
|
|
21
|
+
expect(parseNoteCreatedEvent(validBody).note.id).toBe('n1');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('throws ValidationError for non-JSON bodies', () => {
|
|
25
|
+
expect(() => parseNoteCreatedEvent('not json')).toThrow(ValidationError);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('throws ValidationError for JSON that is not a note.created event', () => {
|
|
29
|
+
expect(() => parseNoteCreatedEvent(JSON.stringify({ type: 'other' }))).toThrow(
|
|
30
|
+
ValidationError,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('processNoteCreated', () => {
|
|
36
|
+
it('logs the processed note', async () => {
|
|
37
|
+
const { log, lines } = collectLogs();
|
|
38
|
+
await processNoteCreated(validBody, { log });
|
|
39
|
+
expect(lines.join('\n')).toContain('n1');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('lets poison messages throw — the DLQ is the safety net, not a catch block', async () => {
|
|
43
|
+
const { log } = collectLogs();
|
|
44
|
+
await expect(processNoteCreated('poison', { log })).rejects.toThrow(ValidationError);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('makeNoteCreatedHandler', () => {
|
|
49
|
+
it('processes every record in the batch', async () => {
|
|
50
|
+
const { log, lines } = collectLogs();
|
|
51
|
+
await makeNoteCreatedHandler({ log })(sqsEvent(validBody, validBody));
|
|
52
|
+
expect(lines).toHaveLength(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('propagates the failure of a poison record', async () => {
|
|
56
|
+
const { log } = collectLogs();
|
|
57
|
+
await expect(makeNoteCreatedHandler({ log })(sqsEvent('poison'))).rejects.toThrow(
|
|
58
|
+
ValidationError,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noUncheckedIndexedAccess": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"isolatedModules": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"types": ["node"]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": true
|
|
5
|
+
},
|
|
6
|
+
"include": [
|
|
7
|
+
"packages/*/src/**/*.ts",
|
|
8
|
+
"packages/*/test/**/*.ts",
|
|
9
|
+
"services/*/src/**/*.ts",
|
|
10
|
+
"services/*/test/**/*.ts",
|
|
11
|
+
"infra/bin/**/*.ts",
|
|
12
|
+
"infra/lib/**/*.ts",
|
|
13
|
+
"infra/test/**/*.ts",
|
|
14
|
+
"vitest.config.ts"
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
include: [
|
|
6
|
+
'packages/*/test/**/*.test.ts',
|
|
7
|
+
'services/*/test/**/*.test.ts',
|
|
8
|
+
'apps/*/test/**/*.test.ts',
|
|
9
|
+
'infra/test/**/*.test.ts',
|
|
10
|
+
],
|
|
11
|
+
// CDK assertion tests bundle the Lambda entries with esbuild.
|
|
12
|
+
testTimeout: 120_000,
|
|
13
|
+
},
|
|
14
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
ci:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: pnpm/action-setup@v4
|
|
13
|
+
with:
|
|
14
|
+
version: 11
|
|
15
|
+
- uses: actions/setup-node@v4
|
|
16
|
+
with:
|
|
17
|
+
node-version: 22
|
|
18
|
+
cache: pnpm
|
|
19
|
+
- run: pnpm install --no-frozen-lockfile
|
|
20
|
+
- run: pnpm lint
|
|
21
|
+
- run: pnpm typecheck
|
|
22
|
+
- run: pnpm test
|