create-svc 0.1.10 → 0.1.12

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 (171) hide show
  1. package/README.md +51 -47
  2. package/index.ts +2 -2
  3. package/package.json +10 -9
  4. package/src/cli.test.ts +28 -10
  5. package/src/cli.ts +196 -33
  6. package/src/git-bootstrap.test.ts +40 -0
  7. package/src/git-bootstrap.ts +110 -0
  8. package/src/naming.test.ts +1 -0
  9. package/src/naming.ts +23 -0
  10. package/src/post-scaffold.test.ts +19 -0
  11. package/src/post-scaffold.ts +17 -4
  12. package/src/profiles.ts +2 -5
  13. package/src/scaffold.test.ts +232 -41
  14. package/src/scaffold.ts +81 -36
  15. package/src/service.test.ts +30 -0
  16. package/src/service.ts +65 -0
  17. package/src/vault.test.ts +61 -1
  18. package/src/vault.ts +77 -15
  19. package/templates/shared/.github/workflows/ci.yml +2 -1
  20. package/templates/shared/.github/workflows/deploy.yml +2 -0
  21. package/templates/shared/README.md +124 -47
  22. package/templates/shared/grafana/alerts.yaml +54 -0
  23. package/templates/shared/grafana/waitlist-dashboard.json +63 -0
  24. package/templates/shared/scripts/authctl.ts +231 -0
  25. package/templates/shared/scripts/cloudrun/bootstrap.ts +14 -5
  26. package/templates/shared/scripts/cloudrun/cleanup.ts +64 -4
  27. package/templates/shared/scripts/cloudrun/cli.ts +329 -7
  28. package/templates/shared/scripts/cloudrun/config.ts +11 -4
  29. package/templates/shared/scripts/cloudrun/deploy.ts +0 -4
  30. package/templates/shared/scripts/cloudrun/lib.ts +174 -41
  31. package/templates/shared/scripts/cloudrun/neon.ts +45 -0
  32. package/templates/shared/scripts/dev.ts +22 -0
  33. package/templates/shared/scripts/ensure-local-db.ts +3 -0
  34. package/templates/shared/scripts/local-docker.ts +63 -0
  35. package/templates/shared/scripts/local-env.ts +27 -0
  36. package/templates/shared/scripts/seed.ts +73 -0
  37. package/templates/shared/scripts/wait-for-db.ts +32 -0
  38. package/templates/shared/service.config.ts +59 -0
  39. package/templates/shared/service.yaml +24 -44
  40. package/templates/targets/workers/.github/workflows/ci.yml +19 -0
  41. package/templates/targets/workers/.github/workflows/deploy.yml +19 -0
  42. package/templates/targets/workers/Makefile +33 -0
  43. package/templates/targets/workers/README.md +75 -0
  44. package/templates/targets/workers/package.json +35 -0
  45. package/templates/targets/workers/scripts/workers/cli.ts +402 -0
  46. package/templates/targets/workers/src/auth.ts +178 -0
  47. package/templates/targets/workers/src/index.ts +198 -0
  48. package/templates/targets/workers/src/storage.ts +370 -0
  49. package/templates/targets/workers/test/app.test.ts +108 -0
  50. package/templates/targets/workers/tsconfig.json +11 -0
  51. package/templates/targets/workers/wrangler.toml +24 -0
  52. package/templates/variants/bun-connectrpc/Makefile +14 -8
  53. package/templates/variants/bun-connectrpc/gen/protos/waitlist/v1/waitlist_pb.ts +424 -0
  54. package/templates/variants/bun-connectrpc/migrations/0000_init.sql +12 -55
  55. package/templates/variants/bun-connectrpc/package.json +12 -5
  56. package/templates/variants/bun-connectrpc/protos/waitlist/v1/waitlist.proto +91 -0
  57. package/templates/variants/bun-connectrpc/scripts/codegen.ts +1 -1
  58. package/templates/variants/bun-connectrpc/scripts/migrate.ts +4 -1
  59. package/templates/variants/bun-connectrpc/src/auth.ts +200 -0
  60. package/templates/variants/bun-connectrpc/src/db/repository.ts +67 -420
  61. package/templates/variants/bun-connectrpc/src/db/schema.ts +15 -64
  62. package/templates/variants/bun-connectrpc/src/index.ts +76 -176
  63. package/templates/variants/bun-connectrpc/src/temporal/activities.ts +14 -0
  64. package/templates/variants/bun-connectrpc/src/temporal/worker.ts +38 -0
  65. package/templates/variants/bun-connectrpc/src/temporal/workflows.ts +10 -0
  66. package/templates/variants/bun-connectrpc/src/waitlist/service.ts +172 -0
  67. package/templates/variants/bun-connectrpc/src/waitlist/types.ts +45 -0
  68. package/templates/variants/bun-connectrpc/test/app.test.ts +4 -4
  69. package/templates/variants/bun-connectrpc/test/waitlist.integration.test.ts +71 -0
  70. package/templates/variants/bun-hono/Makefile +14 -8
  71. package/templates/variants/bun-hono/migrations/0000_init.sql +12 -55
  72. package/templates/variants/bun-hono/package.json +12 -5
  73. package/templates/variants/bun-hono/scripts/migrate.ts +4 -1
  74. package/templates/variants/bun-hono/src/auth.ts +181 -0
  75. package/templates/variants/bun-hono/src/db/repository.ts +68 -421
  76. package/templates/variants/bun-hono/src/db/schema.ts +15 -64
  77. package/templates/variants/bun-hono/src/index.ts +65 -180
  78. package/templates/variants/bun-hono/src/temporal/activities.ts +14 -0
  79. package/templates/variants/bun-hono/src/temporal/worker.ts +38 -0
  80. package/templates/variants/bun-hono/src/temporal/workflows.ts +10 -0
  81. package/templates/variants/bun-hono/src/waitlist/service.ts +166 -0
  82. package/templates/variants/bun-hono/src/waitlist/types.ts +50 -0
  83. package/templates/variants/bun-hono/test/app.test.ts +72 -41
  84. package/templates/variants/bun-hono/test/waitlist.integration.test.ts +102 -0
  85. package/templates/variants/go-chi/Makefile +27 -11
  86. package/templates/variants/go-chi/atlas.hcl +8 -0
  87. package/templates/variants/go-chi/cmd/server/main.go +21 -10
  88. package/templates/variants/go-chi/go.mod +1 -3
  89. package/templates/variants/go-chi/internal/app/service.go +202 -685
  90. package/templates/variants/go-chi/internal/auth/middleware.go +289 -0
  91. package/templates/variants/go-chi/internal/auth/middleware_test.go +38 -0
  92. package/templates/variants/go-chi/internal/config/config.go +27 -11
  93. package/templates/variants/go-chi/internal/httpapi/routes.go +78 -157
  94. package/templates/variants/go-chi/internal/httpapi/waitlist_integration_test.go +199 -0
  95. package/templates/variants/go-chi/internal/temporal/activities.go +27 -0
  96. package/templates/variants/go-chi/internal/temporal/worker.go +42 -0
  97. package/templates/variants/go-chi/internal/temporal/workflows.go +18 -0
  98. package/templates/variants/go-chi/migrations/0000_init.sql +12 -55
  99. package/templates/variants/go-chi/migrations/atlas.sum +2 -0
  100. package/templates/variants/go-chi/package.json +7 -1
  101. package/templates/variants/go-connectrpc/Makefile +26 -9
  102. package/templates/variants/go-connectrpc/atlas.hcl +8 -0
  103. package/templates/variants/go-connectrpc/buf.gen.yaml +2 -2
  104. package/templates/variants/go-connectrpc/cmd/server/main.go +23 -12
  105. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlist.pb.go +960 -0
  106. package/templates/variants/go-connectrpc/gen/waitlist/v1/waitlistv1connect/waitlist.connect.go +283 -0
  107. package/templates/variants/go-connectrpc/go.mod +1 -1
  108. package/templates/variants/go-connectrpc/internal/app/service.go +202 -685
  109. package/templates/variants/go-connectrpc/internal/auth/middleware.go +289 -0
  110. package/templates/variants/go-connectrpc/internal/auth/middleware_test.go +38 -0
  111. package/templates/variants/go-connectrpc/internal/config/config.go +27 -11
  112. package/templates/variants/go-connectrpc/internal/connectapi/handler.go +78 -201
  113. package/templates/variants/go-connectrpc/internal/connectapi/waitlist_integration_test.go +122 -0
  114. package/templates/variants/go-connectrpc/internal/httpapi/routes.go +147 -9
  115. package/templates/variants/go-connectrpc/internal/temporal/activities.go +27 -0
  116. package/templates/variants/go-connectrpc/internal/temporal/worker.go +42 -0
  117. package/templates/variants/go-connectrpc/internal/temporal/workflows.go +18 -0
  118. package/templates/variants/go-connectrpc/migrations/0000_init.sql +12 -55
  119. package/templates/variants/go-connectrpc/migrations/atlas.sum +2 -0
  120. package/templates/variants/go-connectrpc/package.json +7 -1
  121. package/templates/variants/go-connectrpc/protos/waitlist/v1/waitlist.proto +93 -0
  122. package/templates/root/.github/workflows/buf-publish.yml +0 -19
  123. package/templates/root/.github/workflows/ci.yml +0 -26
  124. package/templates/root/.github/workflows/deploy.yml +0 -22
  125. package/templates/root/Dockerfile +0 -23
  126. package/templates/root/README.md +0 -69
  127. package/templates/root/buf.gen.yaml +0 -10
  128. package/templates/root/buf.yaml +0 -9
  129. package/templates/root/cmd/server/main.go +0 -44
  130. package/templates/root/gen/dns/v1/dns.pb.go +0 -623
  131. package/templates/root/gen/dns/v1/dnsv1connect/dns.connect.go +0 -192
  132. package/templates/root/go.mod +0 -10
  133. package/templates/root/internal/app/service.go +0 -152
  134. package/templates/root/internal/app/token_source.go +0 -50
  135. package/templates/root/internal/cloudflare/client.go +0 -160
  136. package/templates/root/internal/config/config.go +0 -55
  137. package/templates/root/internal/connectapi/handler.go +0 -79
  138. package/templates/root/internal/httpapi/routes.go +0 -93
  139. package/templates/root/internal/vault/client.go +0 -148
  140. package/templates/root/package.json +0 -12
  141. package/templates/root/protos/dns/v1/dns.proto +0 -58
  142. package/templates/root/scripts/cloudrun/bootstrap.ts +0 -65
  143. package/templates/root/scripts/cloudrun/config.ts +0 -50
  144. package/templates/root/scripts/cloudrun/deploy.ts +0 -41
  145. package/templates/root/scripts/cloudrun/lib.ts +0 -244
  146. package/templates/root/service.yaml +0 -50
  147. package/templates/root/test/go.test.ts +0 -19
  148. package/templates/shared/scripts/cloudrun/integrations.ts +0 -111
  149. package/templates/variants/bun-connectrpc/gen/protos/chat/v1/chat_pb.ts +0 -1078
  150. package/templates/variants/bun-connectrpc/protos/chat/v1/chat.proto +0 -228
  151. package/templates/variants/bun-connectrpc/src/chat/service.ts +0 -384
  152. package/templates/variants/bun-connectrpc/src/chat/types.ts +0 -142
  153. package/templates/variants/bun-connectrpc/src/storage.ts +0 -72
  154. package/templates/variants/bun-connectrpc/src/webhooks.ts +0 -35
  155. package/templates/variants/bun-connectrpc/test/list-messages.integration.test.ts +0 -182
  156. package/templates/variants/bun-hono/src/chat/service.ts +0 -384
  157. package/templates/variants/bun-hono/src/chat/types.ts +0 -142
  158. package/templates/variants/bun-hono/src/storage.ts +0 -72
  159. package/templates/variants/bun-hono/src/webhooks.ts +0 -35
  160. package/templates/variants/bun-hono/test/list-messages.integration.test.ts +0 -256
  161. package/templates/variants/go-chi/buf.gen.yaml +0 -12
  162. package/templates/variants/go-chi/buf.yaml +0 -9
  163. package/templates/variants/go-chi/cmd/migrate/main.go +0 -101
  164. package/templates/variants/go-chi/internal/httpapi/list_messages_integration_test.go +0 -298
  165. package/templates/variants/go-chi/protos/chat/v1/chat.proto +0 -219
  166. package/templates/variants/go-connectrpc/cmd/migrate/main.go +0 -101
  167. package/templates/variants/go-connectrpc/gen/chat/v1/chat.pb.go +0 -2512
  168. package/templates/variants/go-connectrpc/gen/chat/v1/chatv1connect/chat.connect.go +0 -571
  169. package/templates/variants/go-connectrpc/internal/connectapi/list_messages_integration_test.go +0 -216
  170. package/templates/variants/go-connectrpc/protos/chat/v1/chat.proto +0 -232
  171. /package/bin/{create-svc.mjs → service.mjs} +0 -0
@@ -1,133 +1,73 @@
1
- import { connectNodeAdapter } from "@connectrpc/connect-node";
2
1
  import { Code, ConnectError } from "@connectrpc/connect";
2
+ import { connectNodeAdapter } from "@connectrpc/connect-node";
3
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";
7
-
8
- type RpcService = ServiceImpl<typeof ChatRpcService>;
4
+ import { createServer } from "node:http";
5
+ import { WaitlistService as WaitlistRpcService } from "../gen/protos/waitlist/v1/waitlist_pb.js";
6
+ import { withServiceAuth } from "./auth";
7
+ import { AppError, createDefaultWaitlistService, type WaitlistService } from "./waitlist/service";
8
+ import { startTemporalWorker } from "./temporal/worker";
9
+ import type { WaitlistEntry, WaitlistTrigger } from "./waitlist/types";
10
+
11
+ type RpcService = ServiceImpl<typeof WaitlistRpcService>;
9
12
  type FallbackHandler = NonNullable<Parameters<typeof connectNodeAdapter>[0]["fallback"]>;
10
13
 
11
- export function createRpcService(service: ChatService): Partial<RpcService> {
14
+ export function createRpcService(service: WaitlistService): Partial<RpcService> {
12
15
  return {
13
- async createUser(request) {
14
- const user = await service.createUser({
15
- username: request.username,
16
- displayName: request.displayName || null,
16
+ async joinWaitlist(request) {
17
+ const result = await service.joinWaitlist({
18
+ email: request.email,
19
+ name: request.name || null,
20
+ company: request.company || null,
21
+ source: request.source || null,
17
22
  });
18
- return { user: toRpcUser(user) };
23
+ return { entry: toRpcEntry(result.entry), created: result.created };
19
24
  },
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
- };
25
+ async getWaitlistEntry(request) {
26
+ return { entry: toRpcEntry(await service.getWaitlistEntry(request.entryId)) };
36
27
  },
37
- async getConversation(request) {
38
- return { conversation: toRpcConversation(await service.getConversation(request.conversationId)) };
28
+ async getWaitlistEntryByEmail(request) {
29
+ return { entry: toRpcEntry(await service.getWaitlistEntryByEmail(request.email)) };
39
30
  },
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,
31
+ async listWaitlistEntries(request) {
32
+ const entries = await service.listWaitlistEntries({
33
+ status: request.status || null,
65
34
  limit: request.limit || null,
66
35
  });
67
- return {
68
- messages: result.messages.map(toRpcMessage),
69
- nextCursor: result.nextCursor ?? "",
70
- };
36
+ return { entries: entries.map(toRpcEntry) };
71
37
  },
72
- async createMessage(request) {
38
+ async updateWaitlistEntry(request) {
73
39
  return {
74
- message: toRpcMessage(
75
- await service.createMessage(request.conversationId, {
76
- userId: request.userId,
77
- body: request.body,
40
+ entry: toRpcEntry(
41
+ await service.updateWaitlistEntry({
42
+ entryId: request.entryId,
43
+ status: request.status,
78
44
  })
79
45
  ),
80
46
  };
81
47
  },
82
- async updateMessage(request) {
48
+ async exportWaitlistEntries(request) {
83
49
  return {
84
- message: toRpcMessage(
85
- await service.updateMessage(request.conversationId, request.messageId, { body: request.body })
86
- ),
50
+ csv: await service.exportWaitlistEntries({
51
+ status: request.status || null,
52
+ limit: request.limit || null,
53
+ }),
87
54
  };
88
55
  },
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 {};
56
+ async recordTrigger(request) {
57
+ const trigger = (await service.recordTrigger({
58
+ type: request.type,
59
+ entryId: request.entryId || null,
60
+ payloadJson: request.payloadJson || "{}",
61
+ })) as WaitlistTrigger;
62
+ return { trigger: toRpcTrigger(trigger) };
123
63
  },
124
64
  };
125
65
  }
126
66
 
127
- export function createHandler(service: ChatService) {
67
+ export function createHandler(service: WaitlistService) {
128
68
  return connectNodeAdapter({
129
69
  routes: (router) => {
130
- router.service(ChatRpcService, createRpcService(service));
70
+ router.service(WaitlistRpcService, createRpcService(service));
131
71
  },
132
72
  fallback: (async (request: Parameters<FallbackHandler>[0], response: Parameters<FallbackHandler>[1]) => {
133
73
  const url = new URL(request.url ?? "/", "http://localhost");
@@ -141,7 +81,7 @@ export function createHandler(service: ChatService) {
141
81
  if (path === "/") {
142
82
  respondJson(response, 200, {
143
83
  service: "{{SERVICE_NAME}}",
144
- domain: "chat",
84
+ domain: "waitlist",
145
85
  apiOrigin: "https://api.{{SERVICE_NAME}}.anmho.com",
146
86
  });
147
87
  return;
@@ -156,8 +96,11 @@ export function createHandler(service: ChatService) {
156
96
  try {
157
97
  const provider = path.split("/").filter(Boolean)[1] ?? "generic";
158
98
  const rawBody = await readRawBody(request);
159
- const result = await service.processWebhook(provider, toHeaders(request), rawBody);
160
- respondJson(response, result.duplicate ? 200 : 202, result);
99
+ const trigger = await service.recordTrigger({
100
+ type: `webhook.${provider}`,
101
+ payloadJson: JSON.stringify({ headers: request.headers, rawBody }),
102
+ });
103
+ respondJson(response, 202, { trigger });
161
104
  } catch (error) {
162
105
  respondAppError(response, error);
163
106
  }
@@ -177,9 +120,9 @@ export function createHandler(service: ChatService) {
177
120
 
178
121
  export function createIntrospectionDocument() {
179
122
  return {
180
- service: ChatRpcService.typeName,
181
- file: ChatRpcService.file.proto.name,
182
- methods: ChatRpcService.methods.map((method) => ({
123
+ service: WaitlistRpcService.typeName,
124
+ file: WaitlistRpcService.file.proto.name,
125
+ methods: WaitlistRpcService.methods.map((method) => ({
183
126
  name: method.name,
184
127
  localName: method.localName,
185
128
  kind: method.methodKind,
@@ -197,62 +140,28 @@ export function isLocalRpcIntrospectionEnabled() {
197
140
  return !Bun.env.K_SERVICE && Bun.env.NODE_ENV !== "production";
198
141
  }
199
142
 
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
- }
209
-
210
- function toRpcConversation(conversation: Awaited<ReturnType<ChatService["getConversation"]>>) {
143
+ function toRpcEntry(entry: WaitlistEntry) {
211
144
  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,
145
+ id: entry.id,
146
+ email: entry.email,
147
+ name: entry.name ?? "",
148
+ company: entry.company ?? "",
149
+ source: entry.source ?? "",
150
+ status: entry.status,
151
+ createdAt: entry.createdAt,
152
+ updatedAt: entry.updatedAt,
218
153
  };
219
154
  }
220
155
 
221
- function toRpcMessage(message: Awaited<ReturnType<ChatService["createMessage"]>>) {
156
+ function toRpcTrigger(trigger: WaitlistTrigger) {
222
157
  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,
158
+ id: trigger.id,
159
+ type: trigger.type,
160
+ entryId: trigger.entryId ?? "",
161
+ status: trigger.status,
162
+ payloadJson: trigger.payloadJson,
163
+ createdAt: trigger.createdAt,
164
+ processedAt: trigger.processedAt ?? "",
256
165
  };
257
166
  }
258
167
 
@@ -271,7 +180,7 @@ function respondAppError(response: Parameters<FallbackHandler>[1], error: unknow
271
180
  respondJson(response, 500, { error: error.message, code: error.code });
272
181
  return;
273
182
  }
274
- respondJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
183
+ respondJson(response, 500, { error: error instanceof Error ? error.message : String(error), code: Code[Code.Internal] });
275
184
  }
276
185
 
277
186
  function readRawBody(request: Parameters<FallbackHandler>[0]) {
@@ -283,22 +192,13 @@ function readRawBody(request: Parameters<FallbackHandler>[0]) {
283
192
  });
284
193
  }
285
194
 
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
- }
195
+ if (import.meta.main) {
196
+ const temporalWorker = await startTemporalWorker();
197
+ if (temporalWorker) {
198
+ console.log(`Temporal worker polling ${temporalWorker.taskQueue}`);
296
199
  }
297
- return headers;
298
- }
299
200
 
300
- if (import.meta.main) {
301
201
  const port = Number(Bun.env.PORT ?? 8080);
302
- const server = createServer(createHandler(createDefaultChatService()));
202
+ const server = createServer(withServiceAuth(createHandler(createDefaultWaitlistService())));
303
203
  server.listen(port);
304
204
  }
@@ -0,0 +1,14 @@
1
+ export type WaitlistFollowUpInput = {
2
+ triggerId?: string;
3
+ email?: string;
4
+ type: string;
5
+ };
6
+
7
+ export async function recordWaitlistFollowUp(input: WaitlistFollowUpInput) {
8
+ return {
9
+ status: "queued",
10
+ triggerId: input.triggerId ?? null,
11
+ email: input.email ?? null,
12
+ type: input.type,
13
+ };
14
+ }
@@ -0,0 +1,38 @@
1
+ import { NativeConnection, Worker } from "@temporalio/worker";
2
+ import * as activities from "./activities";
3
+
4
+ export function temporalWorkerEnabled() {
5
+ return (Bun.env.TEMPORAL_ENABLED ?? "").trim().toLowerCase() === "true";
6
+ }
7
+
8
+ export async function startTemporalWorker() {
9
+ if (!temporalWorkerEnabled()) {
10
+ return undefined;
11
+ }
12
+
13
+ const address = Bun.env.TEMPORAL_ADDRESS || "localhost:7233";
14
+ const namespace = Bun.env.TEMPORAL_NAMESPACE || "default";
15
+ const taskQueue = Bun.env.TEMPORAL_TASK_QUEUE || "{{SERVICE_NAME}}";
16
+ const apiKey = Bun.env.TEMPORAL_API_KEY?.trim();
17
+ const connection = await NativeConnection.connect({
18
+ address,
19
+ ...(apiKey ? { apiKey } : {}),
20
+ });
21
+ const worker = await Worker.create({
22
+ connection,
23
+ namespace,
24
+ taskQueue,
25
+ workflowsPath: new URL("./workflows.ts", import.meta.url).pathname,
26
+ activities,
27
+ });
28
+ const running = worker.run();
29
+
30
+ return {
31
+ taskQueue,
32
+ async shutdown() {
33
+ worker.shutdown();
34
+ await running.catch(() => undefined);
35
+ await connection.close();
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,10 @@
1
+ import { proxyActivities } from "@temporalio/workflow";
2
+ import type * as activities from "./activities";
3
+
4
+ const { recordWaitlistFollowUp } = proxyActivities<typeof activities>({
5
+ startToCloseTimeout: "1 minute",
6
+ });
7
+
8
+ export async function waitlistFollowUpWorkflow(input: activities.WaitlistFollowUpInput) {
9
+ return await recordWaitlistFollowUp(input);
10
+ }
@@ -0,0 +1,172 @@
1
+ import { createDb } from "../db/client";
2
+ import { WaitlistRepository } from "../db/repository";
3
+ import type {
4
+ JoinWaitlistInput,
5
+ ListWaitlistEntriesInput,
6
+ RecordTriggerInput,
7
+ UpdateWaitlistEntryInput,
8
+ WaitlistEntry,
9
+ WaitlistEntryStatus,
10
+ } from "./types";
11
+
12
+ export class AppError extends Error {
13
+ constructor(
14
+ readonly status: number,
15
+ readonly code: string,
16
+ message: string
17
+ ) {
18
+ super(message);
19
+ }
20
+ }
21
+
22
+ export type WaitlistService = {
23
+ joinWaitlist(input: JoinWaitlistInput): Promise<{ entry: WaitlistEntry; created: boolean }>;
24
+ getWaitlistEntry(entryId: string): Promise<WaitlistEntry>;
25
+ getWaitlistEntryByEmail(email: string): Promise<WaitlistEntry>;
26
+ listWaitlistEntries(input?: ListWaitlistEntriesInput): Promise<WaitlistEntry[]>;
27
+ updateWaitlistEntry(input: UpdateWaitlistEntryInput): Promise<WaitlistEntry>;
28
+ exportWaitlistEntries(input?: ListWaitlistEntriesInput): Promise<string>;
29
+ recordTrigger(input: RecordTriggerInput): Promise<unknown>;
30
+ };
31
+
32
+ export class DefaultWaitlistService implements WaitlistService {
33
+ constructor(private readonly repository: WaitlistRepository) {}
34
+
35
+ async joinWaitlist(input: JoinWaitlistInput) {
36
+ const email = normalizeEmail(input.email);
37
+ const existing = await this.repository.getEntryByEmail(email);
38
+ if (existing) {
39
+ return { entry: existing, created: false };
40
+ }
41
+
42
+ const entry = await this.repository.createEntry({
43
+ id: crypto.randomUUID(),
44
+ email,
45
+ name: normalizeNullableText(input.name),
46
+ company: normalizeNullableText(input.company),
47
+ source: normalizeNullableText(input.source),
48
+ });
49
+
50
+ await this.repository.createTrigger({
51
+ id: crypto.randomUUID(),
52
+ type: "waitlist.joined",
53
+ entryId: entry.id,
54
+ payloadJson: JSON.stringify({ email: entry.email, source: entry.source }),
55
+ });
56
+
57
+ return { entry, created: true };
58
+ }
59
+
60
+ async getWaitlistEntry(entryId: string) {
61
+ const entry = await this.repository.getEntryById(entryId.trim());
62
+ if (!entry) {
63
+ throw new AppError(404, "entry_not_found", `waitlist entry ${entryId} not found`);
64
+ }
65
+ return entry;
66
+ }
67
+
68
+ async getWaitlistEntryByEmail(email: string) {
69
+ const entry = await this.repository.getEntryByEmail(normalizeEmail(email));
70
+ if (!entry) {
71
+ throw new AppError(404, "entry_not_found", `waitlist entry for ${email} not found`);
72
+ }
73
+ return entry;
74
+ }
75
+
76
+ async listWaitlistEntries(input: ListWaitlistEntriesInput = {}) {
77
+ return this.repository.listEntries({
78
+ status: input.status ? normalizeStatus(input.status) : null,
79
+ limit: input.limit,
80
+ });
81
+ }
82
+
83
+ async updateWaitlistEntry(input: UpdateWaitlistEntryInput) {
84
+ const entryId = input.entryId.trim();
85
+ if (!entryId) {
86
+ throw new AppError(400, "invalid_entry_id", "entry id is required");
87
+ }
88
+ const entry = await this.repository.updateEntryStatus(entryId, normalizeStatus(input.status));
89
+ if (!entry) {
90
+ throw new AppError(404, "entry_not_found", `waitlist entry ${input.entryId} not found`);
91
+ }
92
+ return entry;
93
+ }
94
+
95
+ async exportWaitlistEntries(input: ListWaitlistEntriesInput = {}) {
96
+ return entriesToCsv(await this.listWaitlistEntries(input));
97
+ }
98
+
99
+ async recordTrigger(input: RecordTriggerInput) {
100
+ const type = input.type.trim();
101
+ if (!type) {
102
+ throw new AppError(400, "invalid_trigger_type", "trigger type is required");
103
+ }
104
+
105
+ if (input.entryId) {
106
+ await this.getWaitlistEntry(input.entryId);
107
+ }
108
+
109
+ return this.repository.createTrigger({
110
+ id: crypto.randomUUID(),
111
+ type,
112
+ entryId: input.entryId?.trim() || null,
113
+ payloadJson: normalizePayloadJson(input.payloadJson),
114
+ });
115
+ }
116
+ }
117
+
118
+ export function createDefaultWaitlistService() {
119
+ return new DefaultWaitlistService(new WaitlistRepository(createDb()));
120
+ }
121
+
122
+ function normalizeEmail(value: string) {
123
+ const email = value.trim().toLowerCase();
124
+ if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
125
+ throw new AppError(400, "invalid_email", "valid email is required");
126
+ }
127
+ return email;
128
+ }
129
+
130
+ function normalizeNullableText(value: string | null | undefined) {
131
+ const normalized = value?.trim();
132
+ return normalized ? normalized : null;
133
+ }
134
+
135
+ function normalizeStatus(value: string): WaitlistEntryStatus {
136
+ const status = value.trim().toLowerCase();
137
+ if (status === "joined" || status === "invited" || status === "converted" || status === "archived") {
138
+ return status;
139
+ }
140
+ throw new AppError(400, "invalid_status", "status must be one of joined, invited, converted, archived");
141
+ }
142
+
143
+ function entriesToCsv(entries: WaitlistEntry[]) {
144
+ const headers = ["id", "email", "name", "company", "source", "status", "created_at", "updated_at"];
145
+ return [
146
+ headers.join(","),
147
+ ...entries.map((entry) =>
148
+ [
149
+ entry.id,
150
+ entry.email,
151
+ entry.name ?? "",
152
+ entry.company ?? "",
153
+ entry.source ?? "",
154
+ entry.status,
155
+ entry.createdAt,
156
+ entry.updatedAt,
157
+ ]
158
+ .map(csvCell)
159
+ .join(",")
160
+ ),
161
+ ].join("\n");
162
+ }
163
+
164
+ function csvCell(value: string) {
165
+ return `"${value.replaceAll('"', '""')}"`;
166
+ }
167
+
168
+ function normalizePayloadJson(value: string | null | undefined) {
169
+ const payload = value?.trim() || "{}";
170
+ JSON.parse(payload);
171
+ return payload;
172
+ }
@@ -0,0 +1,45 @@
1
+ export type WaitlistEntry = {
2
+ id: string;
3
+ email: string;
4
+ name: string | null;
5
+ company: string | null;
6
+ source: string | null;
7
+ status: WaitlistEntryStatus;
8
+ createdAt: string;
9
+ updatedAt: string;
10
+ };
11
+
12
+ export type WaitlistTrigger = {
13
+ id: string;
14
+ type: string;
15
+ entryId: string | null;
16
+ status: "queued" | "processed" | "failed";
17
+ payloadJson: string;
18
+ createdAt: string;
19
+ processedAt: string | null;
20
+ };
21
+
22
+ export type JoinWaitlistInput = {
23
+ email: string;
24
+ name?: string | null;
25
+ company?: string | null;
26
+ source?: string | null;
27
+ };
28
+
29
+ export type WaitlistEntryStatus = "joined" | "invited" | "converted" | "archived";
30
+
31
+ export type ListWaitlistEntriesInput = {
32
+ status?: string | null;
33
+ limit?: number | null;
34
+ };
35
+
36
+ export type UpdateWaitlistEntryInput = {
37
+ entryId: string;
38
+ status: string;
39
+ };
40
+
41
+ export type RecordTriggerInput = {
42
+ type: string;
43
+ entryId?: string | null;
44
+ payloadJson?: string | null;
45
+ };
@@ -1,12 +1,12 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { createIntrospectionDocument, isLocalRpcIntrospectionEnabled } from "../src/index";
3
3
 
4
- test("local introspection document exposes chat service and methods", () => {
4
+ test("local introspection document exposes waitlist service and methods", () => {
5
5
  const document = createIntrospectionDocument();
6
6
 
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");
7
+ expect(document.service).toBe("waitlist.v1.WaitlistService");
8
+ expect(document.methods.map((method) => method.name)).toContain("JoinWaitlist");
9
+ expect(document.methods.map((method) => method.name)).toContain("RecordTrigger");
10
10
  });
11
11
 
12
12
  test("local introspection defaults to enabled outside Cloud Run", () => {