@crossone/generator 0.0.0-stage → 0.5.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/README.md CHANGED
@@ -1,3 +1,7 @@
1
- # Temporary Holding Version
1
+ # @crossone/generator
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ CrossOne generator package (Bun runtime required).
4
+
5
+ This package ships TypeScript source for Bun >=1.3.10, not Node.js.
6
+
7
+ Part of the CrossOne framework. Install the matching @crossone/cli version to create an application.
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@crossone/generator",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.5.0",
4
+ "type": "module",
5
+ "description": "CrossOne generator package (Bun runtime required)",
6
+ "homepage": "https://geektech-team.github.io/crossone/generator/",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/geektech-team/crossone.git",
10
+ "directory": "packages/generator"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/geektech-team/crossone/issues"
14
+ },
15
+ "exports": "./src/index.ts",
16
+ "dependencies": {
17
+ "@crossone/template-default": "0.5.0"
18
+ },
19
+ "files": [
20
+ "src"
21
+ ],
22
+ "engines": {
23
+ "bun": ">=1.3.10"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "registry": "https://registry.npmjs.org/"
28
+ }
29
+ }
@@ -0,0 +1,171 @@
1
+ export function migrationSource(slug: string, className: string): string {
2
+ const normalized = slug.replaceAll("-", "_");
3
+ const table = "crossone_data_" + normalized;
4
+ const index = "crossone_index_" + normalized + "_created";
5
+ return `import type { Database } from "bun:sqlite";
6
+
7
+ /** Each business module has an independent, additive migration version. */
8
+ export function migrate${className}(database: Database): void {
9
+ database.transaction(() => {
10
+ database.exec("CREATE TABLE IF NOT EXISTS crossone_migrations (module TEXT NOT NULL, version INTEGER NOT NULL, applied_at TEXT NOT NULL, PRIMARY KEY (module, version))");
11
+ const applied = database.query<{ version: number }, [string]>("SELECT version FROM crossone_migrations WHERE module = ? ORDER BY version DESC LIMIT 1").get(${JSON.stringify(slug)});
12
+ if (applied && applied.version > 1) throw new Error("This module needs a newer application version");
13
+ if (!applied) {
14
+ database.exec("CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, title TEXT NOT NULL CHECK(length(title) BETWEEN 1 AND 120), completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0,1)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL)");
15
+ database.exec("CREATE INDEX IF NOT EXISTS ${index} ON ${table} (created_at, id)");
16
+ database.query("INSERT INTO crossone_migrations (module, version, applied_at) VALUES (?, ?, ?)").run(${JSON.stringify(slug)}, 1, new Date().toISOString());
17
+ }
18
+ })();
19
+ }
20
+ `;
21
+ }
22
+
23
+ export function repositorySource(slug: string, className: string): string {
24
+ const table = "crossone_data_" + slug.replaceAll("-", "_");
25
+ return `import { Database } from "bun:sqlite";
26
+ import { mkdirSync } from "node:fs";
27
+ import { dirname, resolve } from "node:path";
28
+ import { createSchema, updateSchema, querySchema, idSchema, itemSchema, type CreateInput, type UpdateInput, type Item, type ItemList } from "@crossone-app/contracts/modules/${slug}";
29
+ import { migrate${className} } from "./${slug}-migrations";
30
+
31
+ type Row = { id: string; title: string; completed: number; createdAt: string; updatedAt: string };
32
+ const columns = "id, title, completed, created_at AS createdAt, updated_at AS updatedAt";
33
+ function item(row: Row): Item { return itemSchema.parse({ ...row, completed: row.completed === 1 }); }
34
+
35
+ export class ${className}Repository {
36
+ private database?: Database;
37
+ constructor(private readonly databasePath?: string) {}
38
+
39
+ initialize(): void {
40
+ if (this.database) return;
41
+ const configured = this.databasePath ?? process.env.DATABASE_PATH ?? ".data/crossone.sqlite";
42
+ if (!configured.trim()) throw new Error("DATABASE_PATH must not be empty");
43
+ const path = configured === ":memory:" ? configured : resolve(process.cwd(), configured);
44
+ if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
45
+ const database = new Database(path, { create: true, strict: true });
46
+ try {
47
+ database.exec("PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON");
48
+ migrate${className}(database);
49
+ this.database = database;
50
+ } catch (error) { database.close(); throw error; }
51
+ }
52
+
53
+ private connection(): Database {
54
+ if (!this.database) throw new Error("${className}Repository is not initialized");
55
+ return this.database;
56
+ }
57
+
58
+ list(input: unknown = {}): ItemList {
59
+ const query = querySchema.parse(input);
60
+ const database = this.connection();
61
+ // Escape LIKE metacharacters: search is literal text, never SQL syntax.
62
+ const search = "%" + query.search.replace(/[!%_]/g, (character) => "!" + character) + "%";
63
+ return database.transaction(() => {
64
+ const total = database.query<{ total: number }, [string]>("SELECT COUNT(*) AS total FROM ${table} WHERE title LIKE ? ESCAPE '!'").get(search)!.total;
65
+ const rows = database.query<Row, [string, number, number]>("SELECT " + columns + " FROM ${table} WHERE title LIKE ? ESCAPE '!' ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?").all(search, query.limit, query.offset);
66
+ return { items: rows.map(item), total, limit: query.limit, offset: query.offset };
67
+ })();
68
+ }
69
+
70
+ get(id: string): Item | null {
71
+ idSchema.parse({ id });
72
+ const row = this.connection().query<Row, [string]>("SELECT " + columns + " FROM ${table} WHERE id = ?").get(id);
73
+ return row ? item(row) : null;
74
+ }
75
+
76
+ create(input: CreateInput): Item {
77
+ const value = createSchema.parse(input);
78
+ const database = this.connection();
79
+ return database.transaction(() => {
80
+ const id = crypto.randomUUID();
81
+ const now = new Date().toISOString();
82
+ database.query("INSERT INTO ${table} (id, title, completed, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(id, value.title, Number(value.completed), now, now);
83
+ return this.get(id)!;
84
+ })();
85
+ }
86
+
87
+ update(id: string, input: UpdateInput): Item | null {
88
+ const value = updateSchema.parse(input);
89
+ const database = this.connection();
90
+ return database.transaction(() => {
91
+ const current = this.get(id);
92
+ if (!current) return null;
93
+ database.query("UPDATE ${table} SET title = ?, completed = ?, updated_at = ? WHERE id = ?").run(value.title ?? current.title, Number(value.completed ?? current.completed), new Date().toISOString(), id);
94
+ return this.get(id)!;
95
+ })();
96
+ }
97
+
98
+ remove(id: string): boolean {
99
+ idSchema.parse({ id });
100
+ return this.connection().query("DELETE FROM ${table} WHERE id = ?").run(id).changes > 0;
101
+ }
102
+
103
+ close(): void { const database = this.database; this.database = undefined; database?.close(); }
104
+ }
105
+ `;
106
+ }
107
+
108
+ export function controllerSource(slug: string, className: string): string {
109
+ return `import { HttpError, type Context } from "@geektech/backone";
110
+ import { Controller, Get, Post, Patch, Delete, Status, Validate, validatedBody, validatedParams, validatedQuery } from "@geektech/backone/decorators";
111
+ import { createSchema, updateSchema, querySchema, idSchema, itemSchema, listSchema } from "@crossone-app/contracts/modules/${slug}";
112
+ import { ${className}Repository } from "./${slug}-repository";
113
+
114
+ @Controller("/api/${slug}")
115
+ export class ${className}Controller {
116
+ constructor(private readonly repository: ${className}Repository) {}
117
+
118
+ @Get()
119
+ @Validate({ query: querySchema })
120
+ list(ctx: Context) { return listSchema.parse(this.repository.list(validatedQuery(ctx, querySchema))); }
121
+
122
+ @Get("/:id")
123
+ @Validate({ params: idSchema })
124
+ get(ctx: Context) {
125
+ const item = this.repository.get(validatedParams(ctx, idSchema).id);
126
+ if (!item) throw new HttpError(404, "Item not found");
127
+ return itemSchema.parse(item);
128
+ }
129
+
130
+ @Post()
131
+ @Status(201)
132
+ @Validate({ body: createSchema })
133
+ create(ctx: Context) { return itemSchema.parse(this.repository.create(validatedBody(ctx, createSchema))); }
134
+
135
+ @Patch("/:id")
136
+ @Validate({ params: idSchema, body: updateSchema })
137
+ update(ctx: Context) {
138
+ const item = this.repository.update(validatedParams(ctx, idSchema).id, validatedBody(ctx, updateSchema));
139
+ if (!item) throw new HttpError(404, "Item not found");
140
+ return itemSchema.parse(item);
141
+ }
142
+
143
+ @Delete("/:id")
144
+ @Status(204)
145
+ @Validate({ params: idSchema })
146
+ remove(ctx: Context): void {
147
+ if (!this.repository.remove(validatedParams(ctx, idSchema).id)) throw new HttpError(404, "Item not found");
148
+ }
149
+ }
150
+ `;
151
+ }
152
+
153
+ export function moduleSource(slug: string, className: string): string {
154
+ return `import { Module } from "@geektech/backone/decorators";
155
+ import type { ApplicationModule } from "../index";
156
+ import { ${className}Controller } from "./${slug}-controller";
157
+ import { ${className}Repository } from "./${slug}-repository";
158
+
159
+ @Module<${className}Module>({ controllers: (module) => [module.controller] })
160
+ export class ${className}Module {
161
+ readonly controller: ${className}Controller;
162
+ constructor(repository: ${className}Repository) { this.controller = new ${className}Controller(repository); }
163
+ }
164
+
165
+ /** Construct registrations without opening a database or starting a listener. */
166
+ export function create${className}Module(databasePath?: string): ApplicationModule {
167
+ const repository = new ${className}Repository(databasePath);
168
+ return { instance: new ${className}Module(repository), initialize: () => repository.initialize(), dispose: () => repository.close() };
169
+ }
170
+ `;
171
+ }
@@ -0,0 +1,20 @@
1
+ export function clientSource(slug: string, className: string): string {
2
+ return `import { createApiClient, type ApiClientOptions } from "../index";
3
+ import { createSchema, updateSchema, querySchema, idSchema, itemSchema, listSchema, type CreateInput, type UpdateInput, type ListQuery } from "@crossone-app/contracts/modules/${slug}";
4
+
5
+ /** Web defaults to same-origin /api; other transports can inject an adapter/baseURL. */
6
+ export function create${className}Client(options?: ApiClientOptions) {
7
+ const api = createApiClient(options);
8
+ const path = (id: string) => "/${slug}/" + encodeURIComponent(idSchema.parse({ id }).id);
9
+ return {
10
+ list(query: Partial<ListQuery> = {}, signal?: AbortSignal) {
11
+ return api.request({ path: "/${slug}", query: { ...querySchema.parse(query) }, signal }, listSchema);
12
+ },
13
+ get(id: string, signal?: AbortSignal) { return api.request({ path: path(id), signal }, itemSchema); },
14
+ create(input: CreateInput, signal?: AbortSignal) { return api.request({ path: "/${slug}", method: "POST", body: createSchema.parse(input), signal }, itemSchema); },
15
+ update(id: string, input: UpdateInput, signal?: AbortSignal) { return api.request({ path: path(id), method: "PATCH", body: updateSchema.parse(input), signal }, itemSchema); },
16
+ remove(id: string, signal?: AbortSignal) { return api.requestVoid({ path: path(id), method: "DELETE", signal }); },
17
+ };
18
+ }
19
+ `;
20
+ }
@@ -0,0 +1,87 @@
1
+ export const contractsSource = `import type { Schema } from "../index";
2
+
3
+ export type Item = Readonly<{ id: string; title: string; completed: boolean; createdAt: string; updatedAt: string }>;
4
+ export type CreateInput = Readonly<{ title: string; completed?: boolean }>;
5
+ export type UpdateInput = Readonly<{ title?: string; completed?: boolean }>;
6
+ export type ListQuery = Readonly<{ search: string; limit: number; offset: number }>;
7
+ export type ItemList = Readonly<{ items: Item[]; total: number; limit: number; offset: number }>;
8
+
9
+ function invalid(message: string): never { throw new Error(message); }
10
+ function object(value: unknown): Record<string, unknown> {
11
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid("Expected an object");
12
+ return value as Record<string, unknown>;
13
+ }
14
+ function only(value: Record<string, unknown>, keys: readonly string[]): void {
15
+ if (Object.keys(value).some((key) => !keys.includes(key))) invalid("Unknown input field");
16
+ }
17
+ function title(value: unknown): string {
18
+ if (typeof value !== "string") return invalid("title must be a string");
19
+ const normalized = value.trim();
20
+ if ([...normalized].length < 1 || [...normalized].length > 120 || /[\\u0000-\\u001f\\u007f]/.test(normalized)) return invalid("title must contain 1..120 characters on one line");
21
+ return normalized;
22
+ }
23
+ function boolean(value: unknown): boolean {
24
+ if (typeof value !== "boolean") return invalid("completed must be boolean");
25
+ return value;
26
+ }
27
+ function integer(value: unknown, name: string, min: number, max: number): number {
28
+ if ((typeof value !== "number" && typeof value !== "string") || !/^\\d+$/.test(String(value))) return invalid(name + " must be an integer");
29
+ const parsed = Number(value);
30
+ if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) return invalid(name + " is out of range");
31
+ return parsed;
32
+ }
33
+ function responseInteger(value: unknown, name: string, min: number, max: number): number {
34
+ if (typeof value !== "number") return invalid(name + " must be a number in the API response");
35
+ return integer(value, name, min, max);
36
+ }
37
+ function uuid(value: unknown): string {
38
+ if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) return invalid("id must be a UUID");
39
+ return value;
40
+ }
41
+ function timestamp(value: unknown): string {
42
+ if (typeof value !== "string" || !/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) return invalid("Expected a UTC ISO timestamp");
43
+ return value;
44
+ }
45
+
46
+ export const createSchema: Schema<Required<CreateInput>> = {
47
+ parse(input) {
48
+ const value = object(input);
49
+ only(value, ["title", "completed"]);
50
+ return { title: title(value.title), completed: value.completed === undefined ? false : boolean(value.completed) };
51
+ },
52
+ };
53
+ export const updateSchema: Schema<UpdateInput> = {
54
+ parse(input) {
55
+ const value = object(input);
56
+ only(value, ["title", "completed"]);
57
+ if (value.title === undefined && value.completed === undefined) return invalid("Provide title or completed");
58
+ return { ...(value.title !== undefined ? { title: title(value.title) } : {}), ...(value.completed !== undefined ? { completed: boolean(value.completed) } : {}) };
59
+ },
60
+ };
61
+ export const querySchema: Schema<ListQuery> = {
62
+ parse(input) {
63
+ const value = object(input);
64
+ only(value, ["search", "limit", "offset"]);
65
+ if (value.search !== undefined && (typeof value.search !== "string" || [...value.search].length > 120)) return invalid("search must be a string of at most 120 characters");
66
+ return {
67
+ search: typeof value.search === "string" ? value.search.trim() : "",
68
+ limit: value.limit === undefined ? 20 : integer(value.limit, "limit", 1, 100),
69
+ offset: value.offset === undefined ? 0 : integer(value.offset, "offset", 0, Number.MAX_SAFE_INTEGER),
70
+ };
71
+ },
72
+ };
73
+ export const idSchema: Schema<{ id: string }> = { parse(input) { return { id: uuid(object(input).id) }; } };
74
+ export const itemSchema: Schema<Item> = {
75
+ parse(input) {
76
+ const value = object(input);
77
+ return { id: uuid(value.id), title: title(value.title), completed: boolean(value.completed), createdAt: timestamp(value.createdAt), updatedAt: timestamp(value.updatedAt) };
78
+ },
79
+ };
80
+ export const listSchema: Schema<ItemList> = {
81
+ parse(input) {
82
+ const value = object(input);
83
+ if (!Array.isArray(value.items)) return invalid("items must be an array");
84
+ return { items: value.items.map((item) => itemSchema.parse(item)), total: responseInteger(value.total, "total", 0, Number.MAX_SAFE_INTEGER), limit: responseInteger(value.limit, "limit", 1, 100), offset: responseInteger(value.offset, "offset", 0, Number.MAX_SAFE_INTEGER) };
85
+ },
86
+ };
87
+ `;
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { CLIENT_OPTIONS, normalizeClients, type ClientId, type TemplateFile } from "@crossone/template-default";
2
+ import { contractsSource } from "./contracts-source";
3
+ import { clientSource } from "./client-source";
4
+ import { controllerSource, migrationSource, moduleSource, repositorySource } from "./api-source";
5
+ import { controllerTestsSource, repositoryTestsSource } from "./tests-source";
6
+ import { webSource } from "./web-source";
7
+
8
+ export type ModuleTemplateOptions = Readonly<{ name: string; title?: string; preset?: "todo"; clients: readonly string[] }>;
9
+ export type ModuleTemplate = Readonly<{
10
+ slug: string;
11
+ title: string;
12
+ className: string;
13
+ files: TemplateFile[];
14
+ registration: Readonly<{ importLine: string; entry: string }>;
15
+ webPages: Readonly<{ clientId: ClientId; route: string; entry: string }>[];
16
+ }>;
17
+
18
+ const reserved = new Set(["health", "index", "modules", "api", "constructor", "prototype"]);
19
+
20
+ export function getModuleTemplate(options: ModuleTemplateOptions): ModuleTemplate {
21
+ const slug = options.name;
22
+ if (typeof slug !== "string" || slug.length > 64 || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(slug) || reserved.has(slug)) {
23
+ throw new Error("Module name must be a safe lowercase kebab-case slug (1..64 characters); health/index/modules/api are reserved.");
24
+ }
25
+ if (options.preset !== undefined && options.preset !== "todo") throw new Error("Unknown module preset; supported: todo");
26
+ if (typeof options.title !== "undefined" && (typeof options.title !== "string" || /[\u0000-\u001f\u007f\u2028\u2029]/.test(options.title))) throw new Error("Module title must be a single-line string");
27
+ const title = options.title === undefined ? slug.split("-").map((part) => part[0]!.toUpperCase() + part.slice(1)).join(" ") : options.title.trim();
28
+ if (!title || [...title].length > 160) throw new Error("Module title must contain 1..160 characters");
29
+ const clients = normalizeClients(options.clients);
30
+ const web = CLIENT_OPTIONS.filter((client) => clients.includes(client.id) && client.target === "web");
31
+ // An underscore before digit-leading segments prevents class-name collisions:
32
+ // foo-1 and foo1 become Foo_1 and Foo1 respectively.
33
+ const className = slug.split("-").map((part) => /^\d/.test(part) ? "_" + part : part[0]!.toUpperCase() + part.slice(1)).join("");
34
+ const directory = `services/api/src/modules/${slug}`;
35
+ const files: TemplateFile[] = [
36
+ { path: `packages/contracts/src/modules/${slug}.ts`, content: contractsSource },
37
+ { path: `${directory}/${slug}-migrations.ts`, content: migrationSource(slug, className) },
38
+ { path: `${directory}/${slug}-repository.ts`, content: repositorySource(slug, className) },
39
+ { path: `${directory}/${slug}-controller.ts`, content: controllerSource(slug, className) },
40
+ { path: `${directory}/${slug}-module.ts`, content: moduleSource(slug, className) },
41
+ { path: `${directory}/${slug}-repository.test.ts`, content: repositoryTestsSource(slug, className) },
42
+ { path: `${directory}/${slug}-controller.test.ts`, content: controllerTestsSource(slug, className) },
43
+ ...(clients.length ? [{ path: `packages/client/src/modules/${slug}.ts`, content: clientSource(slug, className) }] : []),
44
+ ...web.map((client) => ({ path: `apps/${client.id}/src/modules/${slug}.ts`, content: webSource(slug, title, className) })),
45
+ ];
46
+ return {
47
+ slug, title, className, files,
48
+ registration: { importLine: `import { create${className}Module } from "./${slug}/${slug}-module";`, entry: `create${className}Module()` },
49
+ webPages: web.map((client) => ({ clientId: client.id, route: `/modules/${slug}`, entry: `src/modules/${slug}.ts` })),
50
+ };
51
+ }
@@ -0,0 +1,66 @@
1
+ export function repositoryTestsSource(slug: string, className: string): string {
2
+ return `import { expect, test } from "bun:test";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { ${className}Repository } from "./${slug}-repository";
7
+
8
+ test("${slug}: SQLite CRUD, literal search, migration and persistence", async () => {
9
+ const root = await mkdtemp(join(tmpdir(), "crossone-${slug}-"));
10
+ const path = join(root, "database.sqlite");
11
+ let repository = new ${className}Repository(path);
12
+ try {
13
+ expect(() => repository.list()).toThrow("not initialized");
14
+ repository.initialize();
15
+ const created = repository.create({ title: " O'Reilly 100%_ " });
16
+ expect(created.title).toBe("O'Reilly 100%_");
17
+ expect(created.completed).toBe(false);
18
+ expect(repository.list({ search: "%_" }).items.map((item) => item.id)).toEqual([created.id]);
19
+ expect(repository.update(created.id, { completed: true })?.completed).toBe(true);
20
+ repository.close();
21
+ repository.close();
22
+ repository = new ${className}Repository(path);
23
+ repository.initialize();
24
+ expect(repository.get(created.id)?.completed).toBe(true);
25
+ expect(repository.remove(created.id)).toBe(true);
26
+ expect(repository.remove(created.id)).toBe(false);
27
+ expect(() => repository.create({ title: " " })).toThrow();
28
+ expect(() => repository.update(crypto.randomUUID(), {})).toThrow();
29
+ } finally { repository.close(); await rm(root, { recursive: true, force: true }); }
30
+ });
31
+ `;
32
+ }
33
+
34
+ export function controllerTestsSource(slug: string, className: string): string {
35
+ return `import { expect, test } from "bun:test";
36
+ import { BackOneServer } from "@geektech/backone";
37
+ import { registerModules } from "@geektech/backone/decorators";
38
+ import { create${className}Module } from "./${slug}-module";
39
+
40
+ test("${slug}: HTTP validation, CRUD statuses and missing-item behavior", async () => {
41
+ const registration = create${className}Module(":memory:");
42
+ const server = new BackOneServer();
43
+ registerModules(server, [registration.instance]);
44
+ await registration.initialize?.();
45
+ const request = (path = "", method = "GET", body?: unknown) => server.handle(new Request("http://localhost/api/${slug}" + path, {
46
+ method, ...(body !== undefined ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}),
47
+ }));
48
+ try {
49
+ expect((await request("", "POST", { title: " " })).status).toBe(400);
50
+ expect((await request("?limit=101")).status).toBe(400);
51
+ const response = await request("", "POST", { title: "First item" });
52
+ expect(response.status).toBe(201);
53
+ const item = await response.json() as { id: string };
54
+ expect((await request("/" + item.id)).status).toBe(200);
55
+ const updated = await request("/" + item.id, "PATCH", { completed: true });
56
+ expect(updated.status).toBe(200);
57
+ expect((await updated.json() as { completed: boolean }).completed).toBe(true);
58
+ expect((await request("/" + item.id, "PATCH", {})).status).toBe(400);
59
+ const removed = await request("/" + item.id, "DELETE");
60
+ expect(removed.status).toBe(204);
61
+ expect(await removed.text()).toBe("");
62
+ expect((await request("/" + item.id)).status).toBe(404);
63
+ } finally { await server.close(); await registration.dispose?.(); }
64
+ });
65
+ `;
66
+ }
@@ -0,0 +1,149 @@
1
+ export function webSource(slug: string, title: string, className: string): string {
2
+ return `import { Component, createApp, h, type VNode } from "transone";
3
+ import { create${className}Client } from "@crossone-app/client/modules/${slug}";
4
+ import { clientPath } from "@crossone-app/config";
5
+ import type { Item } from "@crossone-app/contracts/modules/${slug}";
6
+
7
+ interface PageState {
8
+ items: Item[]; total: number; search: string; draftTitle: string;
9
+ editingId: string; editingTitle: string; offset: number; limit: number;
10
+ loading: boolean; saving: boolean; error: string;
11
+ }
12
+
13
+ class ${className}Page extends Component<Record<string, never>, PageState> {
14
+ private readonly api = create${className}Client();
15
+ private request?: AbortController;
16
+ private mutation?: AbortController;
17
+ private revision = 0;
18
+ private pageDisposed = false;
19
+
20
+ protected initState(): PageState {
21
+ return { items: [], total: 0, search: "", draftTitle: "", editingId: "", editingTitle: "", offset: 0, limit: 10, loading: false, saving: false, error: "" };
22
+ }
23
+
24
+ protected initStyles(): void {
25
+ this.styleManager.addStyle("module-page", { selector: ".module-page", properties: { maxWidth: "800px", margin: "32px auto", padding: "24px", fontFamily: "system-ui, sans-serif", color: "#0f172a" } });
26
+ this.styleManager.addStyle("module-row", { selector: ".module-row", properties: { display: "flex", gap: "12px", alignItems: "center", flexWrap: "wrap", padding: "12px 0", borderBottom: "1px solid #cbd5e1" } });
27
+ this.styleManager.addStyle("module-form", { selector: ".module-form", properties: { display: "flex", gap: "8px", margin: "16px 0", flexWrap: "wrap" } });
28
+ this.styleManager.addStyle("module-input", { selector: ".module-page input", properties: { padding: "8px", minWidth: "180px", fontSize: "16px" } });
29
+ this.styleManager.addStyle("module-button", { selector: ".module-page button", properties: { padding: "8px 12px", cursor: "pointer" } });
30
+ }
31
+
32
+ protected onMounted(): void {
33
+ // TransOne's static build mounts components in a simulated browser on Bun.
34
+ // Only an actual browser should start HTTP work; imports/SSR remain offline.
35
+ if (typeof Bun === "undefined") void this.load();
36
+ }
37
+
38
+ protected onUnmounted(): void {
39
+ this.pageDisposed = true;
40
+ this.request?.abort();
41
+ this.mutation?.abort();
42
+ }
43
+
44
+ private errorMessage(error: unknown): string { return error instanceof Error ? error.message : "Unable to complete this request"; }
45
+
46
+ private async load(): Promise<void> {
47
+ if (this.pageDisposed) return;
48
+ this.request?.abort();
49
+ const request = new AbortController();
50
+ this.request = request;
51
+ const revision = ++this.revision;
52
+ this.setState({ loading: true, error: "" });
53
+ try {
54
+ const result = await this.api.list({ search: this.state.search, limit: this.state.limit, offset: this.state.offset }, request.signal);
55
+ if (!this.pageDisposed && revision === this.revision) this.setState({ items: result.items, total: result.total });
56
+ } catch (error) {
57
+ if (!this.pageDisposed && revision === this.revision && !request.signal.aborted) this.setState({ error: this.errorMessage(error) });
58
+ } finally {
59
+ if (!this.pageDisposed && revision === this.revision) this.setState({ loading: false });
60
+ }
61
+ }
62
+
63
+ private async mutate(work: (signal: AbortSignal) => Promise<unknown>): Promise<void> {
64
+ if (this.state.saving || this.pageDisposed) return;
65
+ const mutation = new AbortController();
66
+ this.mutation = mutation;
67
+ this.setState({ saving: true, error: "" });
68
+ try {
69
+ await work(mutation.signal);
70
+ if (!this.pageDisposed) {
71
+ this.setState({ draftTitle: "", editingId: "", editingTitle: "" });
72
+ await this.load();
73
+ }
74
+ } catch (error) {
75
+ if (!this.pageDisposed && !mutation.signal.aborted) this.setState({ error: this.errorMessage(error) });
76
+ } finally { if (!this.pageDisposed) this.setState({ saving: false }); }
77
+ }
78
+
79
+ private create(): void {
80
+ const title = this.state.draftTitle;
81
+ this.setState({ offset: 0 });
82
+ void this.mutate((signal) => this.api.create({ title }, signal));
83
+ }
84
+
85
+ private search(): void { this.setState({ offset: 0 }); void this.load(); }
86
+ private previous(): void { this.setState({ offset: Math.max(0, this.state.offset - this.state.limit) }); void this.load(); }
87
+ private next(): void { this.setState({ offset: this.state.offset + this.state.limit }); void this.load(); }
88
+ private edit(item: Item): void { this.setState({ editingId: item.id, editingTitle: item.title }); }
89
+ private save(): void {
90
+ const id = this.state.editingId;
91
+ const title = this.state.editingTitle;
92
+ void this.mutate((signal) => this.api.update(id, { title }, signal));
93
+ }
94
+ private toggle(item: Item): void { void this.mutate((signal) => this.api.update(item.id, { completed: !item.completed }, signal)); }
95
+ private remove(item: Item): void {
96
+ if (this.state.items.length === 1 && this.state.offset > 0) this.setState({ offset: Math.max(0, this.state.offset - this.state.limit) });
97
+ void this.mutate((signal) => this.api.remove(item.id, signal));
98
+ }
99
+
100
+ private row(item: Item): VNode {
101
+ const editing = this.state.editingId === item.id;
102
+ return { ...h("li", { className: "module-row", "data-testid": "module-row", "data-id": item.id }, [
103
+ h("input", { type: "checkbox", checked: item.completed, disabled: this.state.saving, "aria-label": "Toggle " + item.title, "data-testid": "module-toggle" }, [], { change: () => this.toggle(item) }),
104
+ ...(editing ? [h("form", { className: "module-form" }, [
105
+ h("input", { value: this.state.editingTitle, maxLength: 120, required: true, "aria-label": "Edit title", "data-testid": "module-edit-title" }, [], { input: (event) => this.setState({ editingTitle: (event.target as HTMLInputElement).value }) }),
106
+ h("button", { type: "submit", disabled: this.state.saving, "data-testid": "module-save" }, ["Save"]),
107
+ h("button", { type: "button", disabled: this.state.saving }, ["Cancel"], { click: () => this.setState({ editingId: "", editingTitle: "" }) }),
108
+ ], { submit: (event) => { event.preventDefault(); this.save(); } })] : [
109
+ h("span", { "data-testid": "module-title" }, [item.title]),
110
+ h("span", {}, [item.completed ? "Completed" : "Open"]),
111
+ h("button", { type: "button", disabled: this.state.saving, "data-testid": "module-edit" }, ["Edit"], { click: () => this.edit(item) }),
112
+ ]),
113
+ h("button", { type: "button", disabled: this.state.saving, "data-testid": "module-delete" }, ["Delete"], { click: () => this.remove(item) }),
114
+ ]), key: item.id };
115
+ }
116
+
117
+ protected render(): VNode {
118
+ return h("main", { className: "module-page", "aria-busy": String(this.state.loading || this.state.saving) }, [
119
+ h("a", { href: clientPath("/") }, ["← Home"]),
120
+ h("h1", {}, [${JSON.stringify(title)}]),
121
+ h("form", { className: "module-form" }, [
122
+ h("input", { value: this.state.draftTitle, maxLength: 120, required: true, placeholder: "New item title", "aria-label": "New item title", "data-testid": "module-create-title" }, [], { input: (event) => this.setState({ draftTitle: (event.target as HTMLInputElement).value }) }),
123
+ h("button", { type: "submit", disabled: this.state.saving, "data-testid": "module-create" }, [this.state.saving ? "Saving…" : "Create"]),
124
+ ], { submit: (event) => { event.preventDefault(); this.create(); } }),
125
+ h("form", { className: "module-form" }, [
126
+ h("input", { value: this.state.search, maxLength: 120, placeholder: "Search titles", "aria-label": "Search titles", "data-testid": "module-search-input" }, [], { input: (event) => this.setState({ search: (event.target as HTMLInputElement).value }) }),
127
+ h("button", { type: "submit", disabled: this.state.loading, "data-testid": "module-search" }, ["Search"]),
128
+ ], { submit: (event) => { event.preventDefault(); this.search(); } }),
129
+ ...(this.state.loading ? [h("p", { role: "status", "data-testid": "module-loading" }, ["Loading…"])] : []),
130
+ ...(this.state.error ? [h("div", { role: "alert", "data-testid": "module-error" }, [h("p", {}, [this.state.error]), h("button", { type: "button" }, ["Retry"], { click: () => { void this.load(); } })])] : []),
131
+ ...(!this.state.loading && !this.state.error && !this.state.items.length ? [h("p", { role: "status", "data-testid": "module-empty" }, ["No items found. Create one or adjust your search."])] : []),
132
+ h("ul", { "aria-label": "Items", style: { padding: "0", listStyle: "none" } }, this.state.items.map((item) => this.row(item))),
133
+ h("nav", { "aria-label": "Pagination", className: "module-form" }, [
134
+ h("button", { type: "button", disabled: this.state.offset === 0 || this.state.loading || this.state.saving, "data-testid": "module-previous" }, ["Previous"], { click: () => this.previous() }),
135
+ h("span", { "data-testid": "module-pagination" }, ["Page " + (Math.floor(this.state.offset / this.state.limit) + 1) + " · " + this.state.total + " items"]),
136
+ h("button", { type: "button", disabled: this.state.offset + this.state.limit >= this.state.total || this.state.loading || this.state.saving, "data-testid": "module-next" }, ["Next"], { click: () => this.next() }),
137
+ ]),
138
+ ]);
139
+ }
140
+ }
141
+
142
+ export const app = createApp({ root: ${className}Page, document: {
143
+ lang: "zh-CN", title: ${JSON.stringify(title)},
144
+ body: { tag: "div", props: { id: "app" }, children: [{ component: ${className}Page }] },
145
+ } });
146
+ if (typeof document !== "undefined") document.getElementById("app")?.replaceChildren();
147
+ app.mount();
148
+ `;
149
+ }