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
@@ -0,0 +1,256 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { createApp } from "../src/index";
4
+ import { DefaultChatService } from "../src/chat/service";
5
+ import { ChatRepository } from "../src/db/repository";
6
+ import { createDb } from "../src/db/client";
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 app = createApp(new DefaultChatService(new ChatRepository(createDb(databaseUrl)), storage, new NoopWebhookAdapter()));
41
+
42
+ const user = await createUser(app);
43
+ const conversation = await createConversation(app, user.id);
44
+ const messageIds: string[] = [];
45
+
46
+ for (let index = 1; index <= 55; index += 1) {
47
+ const message = await createMessage(app, conversation.id, user.id, `message-${index}`);
48
+ messageIds.push(message.id);
49
+ }
50
+
51
+ await rewriteMessageTimestamps(messageIds);
52
+
53
+ const uploadResult = await requestJson(app, "/v1/attachments/uploads", {
54
+ method: "POST",
55
+ body: {
56
+ conversation_id: conversation.id,
57
+ user_id: user.id,
58
+ filename: "photo.png",
59
+ content_type: "image/png",
60
+ byte_size: 1234,
61
+ },
62
+ expectedStatus: 201,
63
+ });
64
+ const attachment = uploadResult.result.attachment as {
65
+ id: string;
66
+ publicUrl: string;
67
+ filename: string;
68
+ contentType: string;
69
+ byteSize: number;
70
+ status: string;
71
+ };
72
+
73
+ storage.setObjectMetadataFromUrl(uploadResult.result.attachment.publicUrl, {
74
+ contentType: "image/png",
75
+ byteSize: 1234,
76
+ publicUrl: uploadResult.result.attachment.publicUrl,
77
+ });
78
+
79
+ await requestJson(app, `/v1/attachments/${attachment.id}/finalize`, {
80
+ method: "POST",
81
+ body: {
82
+ message_id: messageIds[54],
83
+ },
84
+ });
85
+
86
+ const firstPage = await requestJson(app, `/v1/conversations/${conversation.id}/messages`);
87
+ expect(firstPage.messages).toHaveLength(50);
88
+ expect(firstPage.messages[0].body).toBe("message-55");
89
+ expect(firstPage.messages[49].body).toBe("message-6");
90
+ expect(firstPage.next_cursor).toBeString();
91
+ expect(firstPage.messages[0].attachments).toEqual([
92
+ {
93
+ id: attachment.id,
94
+ filename: "photo.png",
95
+ content_type: "image/png",
96
+ byte_size: 1234,
97
+ status: "ready",
98
+ public_url: attachment.publicUrl,
99
+ },
100
+ ]);
101
+
102
+ const secondPage = await requestJson(
103
+ app,
104
+ `/v1/conversations/${conversation.id}/messages?cursor=${encodeURIComponent(firstPage.next_cursor)}`
105
+ );
106
+ expect(secondPage.messages.map((message: { body: string }) => message.body)).toEqual([
107
+ "message-5",
108
+ "message-4",
109
+ "message-3",
110
+ "message-2",
111
+ "message-1",
112
+ ]);
113
+ expect(secondPage.next_cursor).toBeUndefined();
114
+
115
+ const invalidLimit = await app.request(`/v1/conversations/${conversation.id}/messages?limit=0`);
116
+ expect(invalidLimit.status).toBe(400);
117
+ expect(await invalidLimit.json()).toEqual({
118
+ error: "limit must be a positive integer",
119
+ code: "invalid_limit",
120
+ });
121
+
122
+ const tooLargeLimit = await app.request(`/v1/conversations/${conversation.id}/messages?limit=101`);
123
+ expect(tooLargeLimit.status).toBe(400);
124
+ expect(await tooLargeLimit.json()).toEqual({
125
+ error: "limit must be at most 100",
126
+ code: "invalid_limit",
127
+ });
128
+ });
129
+
130
+ async function createUser(app: ReturnType<typeof createApp>) {
131
+ const response = await requestJson(app, "/v1/users", {
132
+ method: "POST",
133
+ body: {
134
+ username: "alice",
135
+ display_name: "Alice",
136
+ },
137
+ expectedStatus: 201,
138
+ });
139
+ return response.user as { id: string };
140
+ }
141
+
142
+ async function createConversation(app: ReturnType<typeof createApp>, createdByUserId: string) {
143
+ const response = await requestJson(app, "/v1/conversations", {
144
+ method: "POST",
145
+ body: {
146
+ created_by_user_id: createdByUserId,
147
+ title: "General",
148
+ participant_user_ids: [createdByUserId],
149
+ },
150
+ expectedStatus: 201,
151
+ });
152
+ return response.conversation as { id: string };
153
+ }
154
+
155
+ async function createMessage(app: ReturnType<typeof createApp>, conversationId: string, userId: string, body: string) {
156
+ const response = await requestJson(app, `/v1/conversations/${conversationId}/messages`, {
157
+ method: "POST",
158
+ body: {
159
+ user_id: userId,
160
+ body,
161
+ },
162
+ expectedStatus: 201,
163
+ });
164
+ return response.message as { id: string };
165
+ }
166
+
167
+ async function requestJson(
168
+ app: ReturnType<typeof createApp>,
169
+ path: string,
170
+ input: {
171
+ method?: string;
172
+ body?: unknown;
173
+ expectedStatus?: number;
174
+ } = {}
175
+ ) {
176
+ const response = await app.request(path, {
177
+ method: input.method ?? "GET",
178
+ headers: input.body ? { "Content-Type": "application/json" } : undefined,
179
+ body: input.body ? JSON.stringify(input.body) : undefined,
180
+ });
181
+ expect(response.status).toBe(input.expectedStatus ?? 200);
182
+ return response.json();
183
+ }
184
+
185
+ async function rewriteMessageTimestamps(messageIds: string[]) {
186
+ if (!sql) {
187
+ throw new Error("sql client not initialized");
188
+ }
189
+
190
+ const baseTime = Date.parse("2026-01-01T00:00:00.000Z");
191
+ for (const [index, messageId] of messageIds.entries()) {
192
+ const createdAt = new Date(baseTime + (index + 1) * 1000).toISOString();
193
+ await sql`
194
+ update messages
195
+ set created_at = ${createdAt}::timestamptz,
196
+ updated_at = ${createdAt}::timestamptz
197
+ where id = ${messageId}
198
+ `;
199
+ }
200
+ }
201
+
202
+ class FakeAttachmentStorage implements AttachmentStorage {
203
+ private readonly metadata = new Map<string, AttachmentObjectMetadata>();
204
+
205
+ async createSignedUpload(input: {
206
+ attachmentId: string;
207
+ conversationId: string;
208
+ filename: string;
209
+ contentType: string;
210
+ }): Promise<{ bucket: string; key: string; upload: AttachmentUploadTarget; publicUrl: string }> {
211
+ const bucket = "test-bucket";
212
+ const key = `attachments/${input.conversationId}/${input.attachmentId}/${input.filename}`;
213
+ const publicUrl = `https://storage.test/${key}`;
214
+ return {
215
+ bucket,
216
+ key,
217
+ upload: {
218
+ method: "PUT",
219
+ url: `https://uploads.test/${key}`,
220
+ headers: { "Content-Type": input.contentType },
221
+ },
222
+ publicUrl,
223
+ };
224
+ }
225
+
226
+ async getObjectMetadata(input: { bucket: string; key: string }) {
227
+ const metadata = this.metadata.get(`${input.bucket}/${input.key}`);
228
+ if (!metadata) {
229
+ throw new Error(`missing metadata for ${input.bucket}/${input.key}`);
230
+ }
231
+ return metadata;
232
+ }
233
+
234
+ setObjectMetadataFromUrl(publicUrl: string, input: Omit<AttachmentObjectMetadata, "bucket" | "key">) {
235
+ const [, bucketAndKey = ""] = publicUrl.split("https://storage.test/");
236
+ this.metadata.set(`test-bucket/${bucketAndKey}`, {
237
+ bucket: "test-bucket",
238
+ key: bucketAndKey,
239
+ contentType: input.contentType,
240
+ byteSize: input.byteSize,
241
+ publicUrl: input.publicUrl,
242
+ });
243
+ }
244
+ }
245
+
246
+ class NoopWebhookAdapter {
247
+ async normalize() {
248
+ return {
249
+ provider: "generic",
250
+ externalEventId: "evt_test",
251
+ eventType: "generic.event",
252
+ signatureValid: true,
253
+ payloadJson: "{}",
254
+ };
255
+ }
256
+ }
@@ -4,6 +4,7 @@
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "Bundler",
6
6
  "strict": true,
7
+ "skipLibCheck": true,
7
8
  "types": ["bun"]
8
9
  },
9
10
  "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"]
@@ -1,9 +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
+ WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
4
5
 
5
6
  dev:
6
- go run ./cmd/server
7
+ @$(WITH_ENV) go run ./cmd/server
8
+
9
+ migrate:
10
+ @$(WITH_ENV) go run ./cmd/migrate
7
11
 
8
12
  gen:
9
13
  buf generate
@@ -4,7 +4,9 @@ plugins:
4
4
  out: gen
5
5
  opt:
6
6
  - paths=source_relative
7
+ - Mchat/v1/chat.proto={{MODULE_PATH}}/gen/chat/v1
7
8
  - local: protoc-gen-connect-go
8
9
  out: gen
9
10
  opt:
10
11
  - paths=source_relative
12
+ - Mchat/v1/chat.proto={{MODULE_PATH}}/gen/chat/v1
@@ -0,0 +1,101 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "log"
6
+ "os"
7
+ "sort"
8
+ "strings"
9
+ "time"
10
+
11
+ _ "github.com/jackc/pgx/v5/stdlib"
12
+ "github.com/jmoiron/sqlx"
13
+
14
+ "{{MODULE_PATH}}/internal/app"
15
+ "{{MODULE_PATH}}/internal/config"
16
+ )
17
+
18
+ func main() {
19
+ cfg, err := config.Load()
20
+ if err != nil {
21
+ log.Fatal(err)
22
+ }
23
+
24
+ db, err := app.OpenDatabase(context.Background(), cfg.DatabaseURL)
25
+ if err != nil {
26
+ db, err = waitForDatabase(cfg.DatabaseURL, 30*time.Second)
27
+ if err != nil {
28
+ log.Fatal(err)
29
+ }
30
+ }
31
+
32
+ if _, err := db.ExecContext(context.Background(), `
33
+ create table if not exists schema_migrations (
34
+ version text primary key,
35
+ applied_at timestamptz not null default now()
36
+ )`); err != nil {
37
+ log.Fatal(err)
38
+ }
39
+
40
+ entries, err := os.ReadDir("migrations")
41
+ if err != nil {
42
+ log.Fatal(err)
43
+ }
44
+
45
+ versions := make([]string, 0, len(entries))
46
+ for _, entry := range entries {
47
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
48
+ continue
49
+ }
50
+ versions = append(versions, entry.Name())
51
+ }
52
+ sort.Strings(versions)
53
+
54
+ for _, version := range versions {
55
+ var count int
56
+ if err := db.GetContext(context.Background(), &count, `select count(*) from schema_migrations where version = $1`, version); err != nil {
57
+ log.Fatal(err)
58
+ }
59
+ if count > 0 {
60
+ continue
61
+ }
62
+
63
+ sqlBytes, err := os.ReadFile("migrations/" + version)
64
+ if err != nil {
65
+ log.Fatal(err)
66
+ }
67
+
68
+ tx, err := db.BeginTxx(context.Background(), nil)
69
+ if err != nil {
70
+ log.Fatal(err)
71
+ }
72
+ if _, err := tx.ExecContext(context.Background(), string(sqlBytes)); err != nil {
73
+ _ = tx.Rollback()
74
+ log.Fatal(err)
75
+ }
76
+ if _, err := tx.ExecContext(context.Background(), `insert into schema_migrations (version) values ($1)`, version); err != nil {
77
+ _ = tx.Rollback()
78
+ log.Fatal(err)
79
+ }
80
+ if err := tx.Commit(); err != nil {
81
+ log.Fatal(err)
82
+ }
83
+ log.Printf("applied migration %s", version)
84
+ }
85
+ }
86
+
87
+ func waitForDatabase(databaseURL string, timeout time.Duration) (*sqlx.DB, error) {
88
+ deadline := time.Now().Add(timeout)
89
+ var lastErr error
90
+
91
+ for time.Now().Before(deadline) {
92
+ db, err := app.OpenDatabase(context.Background(), databaseURL)
93
+ if err == nil {
94
+ return db, nil
95
+ }
96
+ lastErr = err
97
+ time.Sleep(time.Second)
98
+ }
99
+
100
+ return nil, lastErr
101
+ }
@@ -6,13 +6,14 @@ import (
6
6
  "net/http"
7
7
  "time"
8
8
 
9
+ "cloud.google.com/go/storage"
9
10
  "github.com/go-chi/chi/v5"
11
+ _ "github.com/jackc/pgx/v5/stdlib"
10
12
  "golang.org/x/net/http2"
11
13
  "golang.org/x/net/http2/h2c"
12
14
 
13
15
  "{{MODULE_PATH}}/internal/app"
14
16
  "{{MODULE_PATH}}/internal/config"
15
- "{{MODULE_PATH}}/internal/connectapi"
16
17
  "{{MODULE_PATH}}/internal/httpapi"
17
18
  )
18
19
 
@@ -22,25 +23,25 @@ func main() {
22
23
  log.Fatal(err)
23
24
  }
24
25
 
25
- service := app.NewDNSService()
26
- if cfg.DatabaseURL != "" {
27
- if _, err := service.CreateRecord(context.Background(), app.CreateRecordInput{
28
- Type: "TXT",
29
- Name: "bootstrap",
30
- Content: "database-configured",
31
- TTL: 60,
32
- Proxied: false,
33
- }); err != nil {
34
- log.Fatal(err)
35
- }
26
+ db, err := app.OpenDatabase(context.Background(), cfg.DatabaseURL)
27
+ if err != nil {
28
+ log.Fatal(err)
29
+ }
30
+
31
+ storageClient, err := storage.NewClient(context.Background())
32
+ if err != nil {
33
+ log.Fatal(err)
36
34
  }
37
35
 
36
+ service := app.NewChatService(
37
+ db,
38
+ app.NewGCSStorage(cfg.AttachmentBucket, cfg.AttachmentPublicBaseURL, storageClient),
39
+ app.GenericWebhookAdapter{},
40
+ )
41
+
38
42
  router := chi.NewRouter()
39
43
  httpapi.RegisterRoutes(router, service)
40
44
 
41
- connectPath, connectHandler := connectapi.NewHandler(service)
42
- router.Mount(connectPath, connectHandler)
43
-
44
45
  server := &http.Server{
45
46
  Addr: ":" + cfg.Port,
46
47
  ReadHeaderTimeout: 10 * time.Second,
@@ -3,8 +3,11 @@ module {{MODULE_PATH}}
3
3
  go 1.25.4
4
4
 
5
5
  require (
6
+ cloud.google.com/go/storage v1.53.0
6
7
  connectrpc.com/connect v1.19.1
7
8
  github.com/go-chi/chi/v5 v5.2.2
9
+ github.com/jackc/pgx/v5 v5.7.5
10
+ github.com/jmoiron/sqlx v1.4.0
8
11
  golang.org/x/net v0.42.0
9
12
  google.golang.org/protobuf v1.36.10
10
13
  )