create-svc 0.1.8 → 0.1.10

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.
Files changed (91) hide show
  1. package/README.md +142 -13
  2. package/package.json +9 -4
  3. package/src/cli.test.ts +29 -8
  4. package/src/cli.ts +103 -70
  5. package/src/naming.test.ts +4 -2
  6. package/src/naming.ts +9 -1
  7. package/src/neon.ts +10 -8
  8. package/src/post-scaffold.ts +7 -28
  9. package/src/profiles.ts +28 -0
  10. package/src/scaffold.test.ts +126 -15
  11. package/src/scaffold.ts +94 -23
  12. package/src/vault.test.ts +62 -5
  13. package/src/vault.ts +24 -4
  14. package/templates/shared/README.md +143 -26
  15. package/templates/shared/docker-compose.yml +19 -0
  16. package/templates/shared/scripts/cloudrun/bootstrap.ts +15 -42
  17. package/templates/shared/scripts/cloudrun/cleanup.ts +17 -31
  18. package/templates/shared/scripts/cloudrun/config.ts +14 -19
  19. package/templates/shared/scripts/cloudrun/deploy.ts +19 -10
  20. package/templates/shared/scripts/cloudrun/integrations.ts +111 -0
  21. package/templates/shared/scripts/cloudrun/lib.ts +88 -112
  22. package/templates/shared/scripts/cloudrun/neon.ts +100 -14
  23. package/templates/shared/service.yaml +44 -1
  24. package/templates/variants/bun-connectrpc/Dockerfile +1 -0
  25. package/templates/variants/bun-connectrpc/Makefile +4 -1
  26. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +1078 -0
  27. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +63 -0
  28. package/templates/variants/bun-connectrpc/package.json +17 -0
  29. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +228 -0
  30. package/templates/variants/bun-connectrpc/scripts/codegen.ts +31 -1
  31. package/templates/variants/bun-connectrpc/scripts/migrate.ts +46 -0
  32. package/templates/variants/bun-connectrpc/src/chat/service.ts +384 -0
  33. package/templates/variants/bun-connectrpc/src/chat/types.ts +142 -0
  34. package/templates/variants/bun-connectrpc/src/db/client.ts +15 -0
  35. package/templates/variants/bun-connectrpc/src/db/repository.ts +479 -0
  36. package/templates/variants/bun-connectrpc/src/db/schema.ts +75 -0
  37. package/templates/variants/bun-connectrpc/src/index.ts +294 -22
  38. package/templates/variants/bun-connectrpc/src/storage.ts +72 -0
  39. package/templates/variants/bun-connectrpc/src/webhooks.ts +35 -0
  40. package/templates/variants/bun-connectrpc/test/app.test.ts +14 -13
  41. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +182 -0
  42. package/templates/variants/bun-connectrpc/tsconfig.json +2 -1
  43. package/templates/variants/bun-hono/Makefile +4 -1
  44. package/templates/variants/bun-hono/migrations/0000_init.sql +63 -0
  45. package/templates/variants/bun-hono/package.json +13 -0
  46. package/templates/variants/bun-hono/scripts/migrate.ts +46 -0
  47. package/templates/variants/bun-hono/src/chat/service.ts +384 -0
  48. package/templates/variants/bun-hono/src/chat/types.ts +142 -0
  49. package/templates/variants/bun-hono/src/db/client.ts +15 -0
  50. package/templates/variants/bun-hono/src/db/repository.ts +479 -0
  51. package/templates/variants/bun-hono/src/db/schema.ts +75 -0
  52. package/templates/variants/bun-hono/src/index.ts +254 -8
  53. package/templates/variants/bun-hono/src/storage.ts +72 -0
  54. package/templates/variants/bun-hono/src/webhooks.ts +35 -0
  55. package/templates/variants/bun-hono/test/app.test.ts +60 -6
  56. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +256 -0
  57. package/templates/variants/bun-hono/tsconfig.json +1 -0
  58. package/templates/variants/go-chi/Makefile +6 -2
  59. package/templates/variants/go-chi/buf.gen.yaml +2 -0
  60. package/templates/variants/go-chi/cmd/migrate/main.go +101 -0
  61. package/templates/variants/go-chi/cmd/server/main.go +16 -15
  62. package/templates/variants/go-chi/go.mod +3 -0
  63. package/templates/variants/go-chi/internal/app/service.go +763 -71
  64. package/templates/variants/go-chi/internal/config/config.go +22 -7
  65. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +298 -0
  66. package/templates/variants/go-chi/internal/httpapi/routes.go +245 -43
  67. package/templates/variants/go-chi/migrations/0000_init.sql +63 -0
  68. package/templates/variants/go-chi/protos/chat/v1/chat.proto +219 -0
  69. package/templates/variants/go-chi/test/go.test.ts +4 -1
  70. package/templates/variants/go-connectrpc/Makefile +6 -2
  71. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -0
  72. package/templates/variants/go-connectrpc/cmd/migrate/main.go +101 -0
  73. package/templates/variants/go-connectrpc/cmd/server/main.go +35 -11
  74. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +2512 -0
  75. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +571 -0
  76. package/templates/variants/go-connectrpc/go.mod +4 -0
  77. package/templates/variants/go-connectrpc/internal/app/service.go +763 -71
  78. package/templates/variants/go-connectrpc/internal/config/config.go +22 -7
  79. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +254 -42
  80. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +216 -0
  81. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +41 -56
  82. package/templates/variants/go-connectrpc/migrations/0000_init.sql +63 -0
  83. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +232 -0
  84. package/templates/shared/.env.example +0 -10
  85. package/templates/variants/go-chi/gen/dns/v1/dns.pb.go +0 -623
  86. package/templates/variants/go-chi/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  87. package/templates/variants/go-chi/internal/connectapi/handler.go +0 -79
  88. package/templates/variants/go-chi/protos/dns/v1/dns.proto +0 -58
  89. package/templates/variants/go-connectrpc/gen/dns/v1/dns.pb.go +0 -623
  90. package/templates/variants/go-connectrpc/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  91. package/templates/variants/go-connectrpc/protos/dns/v1/dns.proto +0 -58
@@ -1,32 +1,304 @@
1
- type ConnectResponse = {
2
- message: string;
3
- };
1
+ import { connectNodeAdapter } from "@connectrpc/connect-node";
2
+ import { Code, ConnectError } from "@connectrpc/connect";
3
+ import type { ServiceImpl } from "@connectrpc/connect";
4
+ import { createServer } from "node:http2";
5
+ import { ChatService as ChatRpcService } from "../gen/protos/chat/v1/chat_pb.js";
6
+ import { AppError, createDefaultChatService, type ChatService } from "./chat/service";
4
7
 
5
- export async function handleRequest(request: Request) {
6
- const url = new URL(request.url);
8
+ type RpcService = ServiceImpl<typeof ChatRpcService>;
9
+ type FallbackHandler = NonNullable<Parameters<typeof connectNodeAdapter>[0]["fallback"]>;
7
10
 
8
- if (url.pathname === "/healthz") {
9
- return Response.json({ status: "ok", runtime: "bun", framework: "connectrpc" });
11
+ export function createRpcService(service: ChatService): Partial<RpcService> {
12
+ return {
13
+ async createUser(request) {
14
+ const user = await service.createUser({
15
+ username: request.username,
16
+ displayName: request.displayName || null,
17
+ });
18
+ return { user: toRpcUser(user) };
19
+ },
20
+ async getUser(request) {
21
+ return { user: toRpcUser(await service.getUser(request.userId)) };
22
+ },
23
+ async getUserByUsername(request) {
24
+ return { user: toRpcUser(await service.getUserByUsername(request.username)) };
25
+ },
26
+ async createConversation(request) {
27
+ return {
28
+ conversation: toRpcConversation(
29
+ await service.createConversation({
30
+ createdByUserId: request.createdByUserId,
31
+ title: request.title || null,
32
+ participantUserIds: request.participantUserIds,
33
+ })
34
+ ),
35
+ };
36
+ },
37
+ async getConversation(request) {
38
+ return { conversation: toRpcConversation(await service.getConversation(request.conversationId)) };
39
+ },
40
+ async updateConversation(request) {
41
+ return {
42
+ conversation: toRpcConversation(
43
+ await service.updateConversation(request.conversationId, { title: request.title || null })
44
+ ),
45
+ };
46
+ },
47
+ async deleteConversation(request) {
48
+ await service.deleteConversation(request.conversationId);
49
+ return {};
50
+ },
51
+ async addConversationParticipant(request) {
52
+ return {
53
+ conversation: toRpcConversation(
54
+ await service.addParticipant(request.conversationId, request.userId)
55
+ ),
56
+ };
57
+ },
58
+ async removeConversationParticipant(request) {
59
+ await service.removeParticipant(request.conversationId, request.userId);
60
+ return {};
61
+ },
62
+ async listMessages(request) {
63
+ const result = await service.listMessages(request.conversationId, {
64
+ cursor: request.cursor || null,
65
+ limit: request.limit || null,
66
+ });
67
+ return {
68
+ messages: result.messages.map(toRpcMessage),
69
+ nextCursor: result.nextCursor ?? "",
70
+ };
71
+ },
72
+ async createMessage(request) {
73
+ return {
74
+ message: toRpcMessage(
75
+ await service.createMessage(request.conversationId, {
76
+ userId: request.userId,
77
+ body: request.body,
78
+ })
79
+ ),
80
+ };
81
+ },
82
+ async updateMessage(request) {
83
+ return {
84
+ message: toRpcMessage(
85
+ await service.updateMessage(request.conversationId, request.messageId, { body: request.body })
86
+ ),
87
+ };
88
+ },
89
+ async deleteMessage(request) {
90
+ await service.deleteMessage(request.conversationId, request.messageId);
91
+ return {};
92
+ },
93
+ async createAttachmentUpload(request) {
94
+ const result = await service.createAttachmentUpload({
95
+ conversationId: request.conversationId,
96
+ uploadedByUserId: request.userId,
97
+ filename: request.filename,
98
+ contentType: request.contentType,
99
+ byteSize: Number(request.byteSize),
100
+ });
101
+ return {
102
+ attachment: toRpcAttachment(result.attachment),
103
+ upload: {
104
+ method: result.upload.method,
105
+ url: result.upload.url,
106
+ headers: result.upload.headers,
107
+ },
108
+ };
109
+ },
110
+ async finalizeAttachment(request) {
111
+ return {
112
+ attachment: toRpcAttachment(
113
+ await service.finalizeAttachment(request.attachmentId, { messageId: request.messageId || null })
114
+ ),
115
+ };
116
+ },
117
+ async getAttachment(request) {
118
+ return { attachment: toRpcAttachment(await service.getAttachment(request.attachmentId)) };
119
+ },
120
+ async deleteAttachment(request) {
121
+ await service.deleteAttachment(request.attachmentId);
122
+ return {};
123
+ },
124
+ };
125
+ }
126
+
127
+ export function createHandler(service: ChatService) {
128
+ return connectNodeAdapter({
129
+ routes: (router) => {
130
+ router.service(ChatRpcService, createRpcService(service));
131
+ },
132
+ fallback: (async (request: Parameters<FallbackHandler>[0], response: Parameters<FallbackHandler>[1]) => {
133
+ const url = new URL(request.url ?? "/", "http://localhost");
134
+ const path = url.pathname;
135
+
136
+ if (path === "/healthz" || path === "/readyz") {
137
+ respondJson(response, 200, { status: "ok" });
138
+ return;
139
+ }
140
+
141
+ if (path === "/") {
142
+ respondJson(response, 200, {
143
+ service: "{{SERVICE_NAME}}",
144
+ domain: "chat",
145
+ apiOrigin: "https://api.{{SERVICE_NAME}}.anmho.com",
146
+ });
147
+ return;
148
+ }
149
+
150
+ if (path === "/debug/connectrpc" && isLocalRpcIntrospectionEnabled()) {
151
+ respondJson(response, 200, createIntrospectionDocument());
152
+ return;
153
+ }
154
+
155
+ if (request.method === "POST" && path.startsWith("/webhooks/")) {
156
+ try {
157
+ const provider = path.split("/").filter(Boolean)[1] ?? "generic";
158
+ const rawBody = await readRawBody(request);
159
+ const result = await service.processWebhook(provider, toHeaders(request), rawBody);
160
+ respondJson(response, result.duplicate ? 200 : 202, result);
161
+ } catch (error) {
162
+ respondAppError(response, error);
163
+ }
164
+ return;
165
+ }
166
+
167
+ if (request.method === "GET" && path.startsWith("/webhooks/") && path.endsWith("/health")) {
168
+ const provider = path.split("/").filter(Boolean)[1] ?? "generic";
169
+ respondJson(response, 200, { status: "ok", provider });
170
+ return;
171
+ }
172
+
173
+ respondJson(response, 404, { error: "not found" });
174
+ }) as FallbackHandler,
175
+ });
176
+ }
177
+
178
+ export function createIntrospectionDocument() {
179
+ return {
180
+ service: ChatRpcService.typeName,
181
+ file: ChatRpcService.file.proto.name,
182
+ methods: ChatRpcService.methods.map((method) => ({
183
+ name: method.name,
184
+ localName: method.localName,
185
+ kind: method.methodKind,
186
+ input: method.input.typeName,
187
+ output: method.output.typeName,
188
+ })),
189
+ };
190
+ }
191
+
192
+ export function isLocalRpcIntrospectionEnabled() {
193
+ const override = Bun.env.ENABLE_RPC_INTROSPECTION?.trim().toLowerCase();
194
+ if (override) {
195
+ return !["0", "false", "no", "off"].includes(override);
10
196
  }
197
+ return !Bun.env.K_SERVICE && Bun.env.NODE_ENV !== "production";
198
+ }
199
+
200
+ function toRpcUser(user: Awaited<ReturnType<ChatService["getUser"]>>) {
201
+ return {
202
+ id: user.id,
203
+ username: user.username,
204
+ displayName: user.displayName ?? "",
205
+ createdAt: user.createdAt,
206
+ updatedAt: user.updatedAt,
207
+ };
208
+ }
11
209
 
12
- if (url.pathname === "/rpc.example.v1.Service/Ping" && request.method === "POST") {
13
- const payload = (await request.json().catch(() => ({}))) as { name?: string };
14
- const body: ConnectResponse = {
15
- message: `hello ${payload.name?.trim() || "{{SERVICE_NAME}}"}`,
16
- };
17
- return Response.json(body, {
18
- headers: {
19
- "Content-Type": "application/json",
20
- },
21
- });
210
+ function toRpcConversation(conversation: Awaited<ReturnType<ChatService["getConversation"]>>) {
211
+ return {
212
+ id: conversation.id,
213
+ title: conversation.title ?? "",
214
+ createdByUserId: conversation.createdByUserId,
215
+ participants: conversation.participants.map(toRpcUser),
216
+ createdAt: conversation.createdAt,
217
+ updatedAt: conversation.updatedAt,
218
+ };
219
+ }
220
+
221
+ function toRpcMessage(message: Awaited<ReturnType<ChatService["createMessage"]>>) {
222
+ return {
223
+ id: message.id,
224
+ conversationId: message.conversationId,
225
+ userId: message.userId,
226
+ body: message.body,
227
+ editedAt: message.editedAt ?? "",
228
+ createdAt: message.createdAt,
229
+ updatedAt: message.updatedAt,
230
+ attachments: message.attachments.map((attachment) => ({
231
+ id: attachment.id,
232
+ filename: attachment.filename,
233
+ contentType: attachment.contentType,
234
+ byteSize: BigInt(attachment.byteSize),
235
+ status: attachment.status,
236
+ publicUrl: attachment.publicUrl,
237
+ })),
238
+ };
239
+ }
240
+
241
+ function toRpcAttachment(attachment: Awaited<ReturnType<ChatService["getAttachment"]>>) {
242
+ return {
243
+ id: attachment.id,
244
+ conversationId: attachment.conversationId,
245
+ messageId: attachment.messageId ?? "",
246
+ uploadedByUserId: attachment.uploadedByUserId,
247
+ storageBucket: attachment.storageBucket,
248
+ storageKey: attachment.storageKey,
249
+ contentType: attachment.contentType,
250
+ byteSize: BigInt(attachment.byteSize),
251
+ filename: attachment.filename,
252
+ status: attachment.status,
253
+ publicUrl: attachment.publicUrl,
254
+ createdAt: attachment.createdAt,
255
+ updatedAt: attachment.updatedAt,
256
+ };
257
+ }
258
+
259
+ function respondJson(response: Parameters<FallbackHandler>[1], status: number, body: unknown) {
260
+ response.statusCode = status;
261
+ response.setHeader("Content-Type", "application/json");
262
+ response.end(JSON.stringify(body));
263
+ }
264
+
265
+ function respondAppError(response: Parameters<FallbackHandler>[1], error: unknown) {
266
+ if (error instanceof AppError) {
267
+ respondJson(response, error.status, { error: error.message, code: error.code });
268
+ return;
22
269
  }
270
+ if (error instanceof ConnectError) {
271
+ respondJson(response, 500, { error: error.message, code: error.code });
272
+ return;
273
+ }
274
+ respondJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
275
+ }
23
276
 
24
- return Response.json({ error: "not found" }, { status: 404 });
277
+ function readRawBody(request: Parameters<FallbackHandler>[0]) {
278
+ return new Promise<string>((resolve, reject) => {
279
+ const chunks: Buffer[] = [];
280
+ request.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
281
+ request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
282
+ request.on("error", reject);
283
+ });
284
+ }
285
+
286
+ function toHeaders(request: Parameters<FallbackHandler>[0]) {
287
+ const headers = new Headers();
288
+ for (const [key, value] of Object.entries(request.headers)) {
289
+ if (Array.isArray(value)) {
290
+ for (const item of value) {
291
+ headers.append(key, item);
292
+ }
293
+ } else if (value) {
294
+ headers.set(key, value);
295
+ }
296
+ }
297
+ return headers;
25
298
  }
26
299
 
27
300
  if (import.meta.main) {
28
- Bun.serve({
29
- port: Number(Bun.env.PORT ?? 8080),
30
- fetch: handleRequest,
31
- });
301
+ const port = Number(Bun.env.PORT ?? 8080);
302
+ const server = createServer(createHandler(createDefaultChatService()));
303
+ server.listen(port);
32
304
  }
@@ -0,0 +1,72 @@
1
+ import { Storage } from "@google-cloud/storage";
2
+ import type { AttachmentObjectMetadata, AttachmentUploadTarget } from "./chat/types";
3
+
4
+ export type AttachmentStorage = {
5
+ createSignedUpload(input: {
6
+ attachmentId: string;
7
+ conversationId: string;
8
+ filename: string;
9
+ contentType: string;
10
+ }): Promise<{ bucket: string; key: string; upload: AttachmentUploadTarget; publicUrl: string }>;
11
+ getObjectMetadata(input: { bucket: string; key: string }): Promise<AttachmentObjectMetadata>;
12
+ };
13
+
14
+ export class GcsAttachmentStorage implements AttachmentStorage {
15
+ constructor(
16
+ private readonly bucketName = requireAttachmentBucket(),
17
+ private readonly publicBaseUrl = Bun.env.ATTACHMENT_PUBLIC_BASE_URL?.trim() || `https://storage.googleapis.com/${requireAttachmentBucket()}`
18
+ ) {}
19
+
20
+ async createSignedUpload(input: { attachmentId: string; conversationId: string; filename: string; contentType: string }) {
21
+ const storage = new Storage();
22
+ const bucket = storage.bucket(this.bucketName);
23
+ const key = `attachments/${input.conversationId}/${input.attachmentId}/${sanitizeFilename(input.filename)}`;
24
+ const file = bucket.file(key);
25
+ const [url] = await file.getSignedUrl({
26
+ action: "write",
27
+ version: "v4",
28
+ expires: Date.now() + 15 * 60 * 1000,
29
+ contentType: input.contentType,
30
+ });
31
+ return {
32
+ bucket: this.bucketName,
33
+ key,
34
+ upload: {
35
+ method: "PUT" as const,
36
+ url,
37
+ headers: {
38
+ "Content-Type": input.contentType,
39
+ },
40
+ },
41
+ publicUrl: `${this.publicBaseUrl.replace(/\/+$/g, "")}/${key}`,
42
+ };
43
+ }
44
+
45
+ async getObjectMetadata(input: { bucket: string; key: string }): Promise<AttachmentObjectMetadata> {
46
+ const storage = new Storage();
47
+ const [metadata] = await storage.bucket(input.bucket).file(input.key).getMetadata();
48
+ return {
49
+ bucket: input.bucket,
50
+ key: input.key,
51
+ contentType: String(metadata.contentType ?? ""),
52
+ byteSize: Number(metadata.size ?? 0),
53
+ publicUrl: `${this.publicBaseUrl.replace(/\/+$/g, "")}/${input.key}`,
54
+ };
55
+ }
56
+ }
57
+
58
+ export function createAttachmentStorage() {
59
+ return new GcsAttachmentStorage();
60
+ }
61
+
62
+ export function requireAttachmentBucket() {
63
+ const bucket = Bun.env.ATTACHMENT_BUCKET?.trim();
64
+ if (!bucket) {
65
+ throw new Error("ATTACHMENT_BUCKET is required");
66
+ }
67
+ return bucket;
68
+ }
69
+
70
+ function sanitizeFilename(filename: string) {
71
+ return filename.trim().replace(/[^a-zA-Z0-9._-]+/g, "-") || "upload.bin";
72
+ }
@@ -0,0 +1,35 @@
1
+ import type { NormalizedWebhookEvent } from "./chat/types";
2
+
3
+ export type WebhookAdapter = {
4
+ normalize(provider: string, headers: Headers, rawBody: string): Promise<NormalizedWebhookEvent>;
5
+ };
6
+
7
+ export class GenericJsonWebhookAdapter implements WebhookAdapter {
8
+ async normalize(provider: string, headers: Headers, rawBody: string): Promise<NormalizedWebhookEvent> {
9
+ const payload = parseJson(rawBody);
10
+ const secret = Bun.env[`WEBHOOK_${provider.toUpperCase()}_SECRET`]?.trim();
11
+ const incomingSecret = headers.get("x-webhook-secret")?.trim() ?? "";
12
+ const externalEventId = String(payload.id ?? headers.get("x-event-id") ?? crypto.randomUUID());
13
+ const eventType = String(payload.type ?? headers.get("x-event-type") ?? "generic.event");
14
+
15
+ return {
16
+ provider,
17
+ externalEventId,
18
+ eventType,
19
+ signatureValid: secret ? incomingSecret === secret : true,
20
+ payloadJson: rawBody,
21
+ };
22
+ }
23
+ }
24
+
25
+ export function createWebhookAdapter() {
26
+ return new GenericJsonWebhookAdapter();
27
+ }
28
+
29
+ function parseJson(rawBody: string) {
30
+ try {
31
+ return JSON.parse(rawBody) as Record<string, unknown>;
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
@@ -1,17 +1,18 @@
1
1
  import { expect, test } from "bun:test";
2
- import { handleRequest } from "../src/index";
2
+ import { createIntrospectionDocument, isLocalRpcIntrospectionEnabled } from "../src/index";
3
3
 
4
- test("connect-style ping route responds", async () => {
5
- const response = await handleRequest(
6
- new Request("http://localhost/rpc.example.v1.Service/Ping", {
7
- method: "POST",
8
- body: JSON.stringify({ name: "preview" }),
9
- headers: {
10
- "Content-Type": "application/json",
11
- },
12
- })
13
- );
4
+ test("local introspection document exposes chat service and methods", () => {
5
+ const document = createIntrospectionDocument();
14
6
 
15
- expect(response.status).toBe(200);
16
- expect(await response.json()).toEqual({ message: "hello preview" });
7
+ expect(document.service).toBe("chat.v1.ChatService");
8
+ expect(document.methods.map((method) => method.name)).toContain("CreateUser");
9
+ expect(document.methods.map((method) => method.name)).toContain("CreateAttachmentUpload");
10
+ });
11
+
12
+ test("local introspection defaults to enabled outside Cloud Run", () => {
13
+ delete Bun.env.K_SERVICE;
14
+ delete Bun.env.ENABLE_RPC_INTROSPECTION;
15
+ Bun.env.NODE_ENV = "development";
16
+
17
+ expect(isLocalRpcIntrospectionEnabled()).toBeTrue();
17
18
  });
@@ -0,0 +1,182 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { createDb } from "../src/db/client";
4
+ import { ChatRepository } from "../src/db/repository";
5
+ import { DefaultChatService } from "../src/chat/service";
6
+ import { createRpcService } from "../src/index";
7
+ import type { AttachmentObjectMetadata, AttachmentUploadTarget } from "../src/chat/types";
8
+ import type { AttachmentStorage } from "../src/storage";
9
+
10
+ const databaseUrl = Bun.env.DATABASE_URL?.trim();
11
+ const integrationTest = databaseUrl ? test : test.skip;
12
+
13
+ let sql: SQL | null = null;
14
+
15
+ beforeEach(async () => {
16
+ if (!databaseUrl) {
17
+ return;
18
+ }
19
+ sql = new SQL(databaseUrl);
20
+ await sql.unsafe(`
21
+ truncate table
22
+ webhook_events,
23
+ attachments,
24
+ messages,
25
+ conversation_participants,
26
+ conversations,
27
+ users
28
+ restart identity cascade
29
+ `);
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await sql?.end();
34
+ sql = null;
35
+ });
36
+
37
+ integrationTest("list messages returns newest-first pages with attachment metadata", async () => {
38
+ Bun.env.ATTACHMENT_PUBLIC_BASE_URL = "https://storage.test";
39
+ const storage = new FakeAttachmentStorage();
40
+ const rpc = createRpcService(new DefaultChatService(new ChatRepository(createDb(databaseUrl)), storage, new NoopWebhookAdapter()));
41
+
42
+ const user = (await rpc.createUser!({ username: "alice", displayName: "Alice" } as any, undefined as never)).user!;
43
+ const conversation = (
44
+ await rpc.createConversation!({
45
+ createdByUserId: user.id!,
46
+ title: "General",
47
+ participantUserIds: [user.id!],
48
+ } as any, undefined as never)
49
+ ).conversation!;
50
+
51
+ const messageIds: string[] = [];
52
+ for (let index = 1; index <= 55; index += 1) {
53
+ const response = await rpc.createMessage!({
54
+ conversationId: conversation.id!,
55
+ userId: user.id!,
56
+ body: `message-${index}`,
57
+ } as any, undefined as never);
58
+ messageIds.push(response.message!.id!);
59
+ }
60
+ await rewriteMessageTimestamps(messageIds);
61
+
62
+ const uploadResult = await rpc.createAttachmentUpload!({
63
+ conversationId: conversation.id!,
64
+ userId: user.id!,
65
+ filename: "photo.png",
66
+ contentType: "image/png",
67
+ byteSize: BigInt(1234),
68
+ } as any, undefined as never);
69
+ storage.setObjectMetadata(uploadResult.attachment!.publicUrl!, {
70
+ contentType: "image/png",
71
+ byteSize: 1234,
72
+ publicUrl: uploadResult.attachment!.publicUrl!,
73
+ });
74
+ await rpc.finalizeAttachment!({
75
+ attachmentId: uploadResult.attachment!.id!,
76
+ messageId: messageIds[54]!,
77
+ } as any, undefined as never);
78
+
79
+ const firstPage = await rpc.listMessages!({
80
+ conversationId: conversation.id!,
81
+ } as any, undefined as never);
82
+ expect(firstPage.messages!).toHaveLength(50);
83
+ expect(firstPage.messages![0]?.body).toBe("message-55");
84
+ expect(firstPage.messages![49]?.body).toBe("message-6");
85
+ expect(firstPage.nextCursor).toBeString();
86
+ expect(firstPage.messages![0]?.attachments).toEqual([
87
+ {
88
+ id: uploadResult.attachment!.id!,
89
+ filename: "photo.png",
90
+ contentType: "image/png",
91
+ byteSize: BigInt(1234),
92
+ status: "ready",
93
+ publicUrl: uploadResult.attachment!.publicUrl!,
94
+ },
95
+ ]);
96
+
97
+ const secondPage = await rpc.listMessages!({
98
+ conversationId: conversation.id!,
99
+ cursor: firstPage.nextCursor,
100
+ } as any, undefined as never);
101
+ expect(secondPage.messages!.map((message) => message.body)).toEqual([
102
+ "message-5",
103
+ "message-4",
104
+ "message-3",
105
+ "message-2",
106
+ "message-1",
107
+ ]);
108
+ expect(secondPage.nextCursor).toBe("");
109
+ });
110
+
111
+ async function rewriteMessageTimestamps(messageIds: string[]) {
112
+ if (!sql) {
113
+ throw new Error("sql client not initialized");
114
+ }
115
+
116
+ const baseTime = Date.parse("2026-01-01T00:00:00.000Z");
117
+ for (const [index, messageId] of messageIds.entries()) {
118
+ const createdAt = new Date(baseTime + (index + 1) * 1000).toISOString();
119
+ await sql`
120
+ update messages
121
+ set created_at = ${createdAt}::timestamptz,
122
+ updated_at = ${createdAt}::timestamptz
123
+ where id = ${messageId}
124
+ `;
125
+ }
126
+ }
127
+
128
+ class FakeAttachmentStorage implements AttachmentStorage {
129
+ private readonly metadata = new Map<string, AttachmentObjectMetadata>();
130
+
131
+ async createSignedUpload(input: {
132
+ attachmentId: string;
133
+ conversationId: string;
134
+ filename: string;
135
+ contentType: string;
136
+ }): Promise<{ bucket: string; key: string; upload: AttachmentUploadTarget; publicUrl: string }> {
137
+ const bucket = "test-bucket";
138
+ const key = `attachments/${input.conversationId}/${input.attachmentId}/${input.filename}`;
139
+ const publicUrl = `https://storage.test/${key}`;
140
+ return {
141
+ bucket,
142
+ key,
143
+ upload: {
144
+ method: "PUT",
145
+ url: `https://uploads.test/${key}`,
146
+ headers: { "Content-Type": input.contentType },
147
+ },
148
+ publicUrl,
149
+ };
150
+ }
151
+
152
+ async getObjectMetadata(input: { bucket: string; key: string }) {
153
+ const metadata = this.metadata.get(`${input.bucket}/${input.key}`);
154
+ if (!metadata) {
155
+ throw new Error(`missing metadata for ${input.bucket}/${input.key}`);
156
+ }
157
+ return metadata;
158
+ }
159
+
160
+ setObjectMetadata(publicUrl: string, input: Omit<AttachmentObjectMetadata, "bucket" | "key">) {
161
+ const [, key = ""] = publicUrl.split("https://storage.test/");
162
+ this.metadata.set(`test-bucket/${key}`, {
163
+ bucket: "test-bucket",
164
+ key,
165
+ contentType: input.contentType,
166
+ byteSize: input.byteSize,
167
+ publicUrl: input.publicUrl,
168
+ });
169
+ }
170
+ }
171
+
172
+ class NoopWebhookAdapter {
173
+ async normalize() {
174
+ return {
175
+ provider: "generic",
176
+ externalEventId: "evt_test",
177
+ eventType: "generic.event",
178
+ signatureValid: true,
179
+ payloadJson: "{}",
180
+ };
181
+ }
182
+ }
@@ -4,7 +4,8 @@
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "Bundler",
6
6
  "strict": true,
7
+ "skipLibCheck": true,
7
8
  "types": ["bun"]
8
9
  },
9
- "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"]
10
+ "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "gen/**/*.ts"]
10
11
  }
@@ -1,10 +1,13 @@
1
- .PHONY: dev gen lint test bootstrap deploy cleanup
1
+ .PHONY: dev migrate gen lint test bootstrap deploy cleanup
2
2
 
3
3
  CLOUDRUN := npx --no-install svc-cloudrun
4
4
 
5
5
  dev:
6
6
  bun run ./src/index.ts
7
7
 
8
+ migrate:
9
+ bun run ./scripts/migrate.ts
10
+
8
11
  gen:
9
12
  bun run ./scripts/codegen.ts
10
13