ta_data_mas 0.0.2

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 (219) hide show
  1. package/README.md +42 -0
  2. package/apps/api/.prettierrc +4 -0
  3. package/apps/api/README.md +98 -0
  4. package/apps/api/drizzle/0000_right_drax.sql +87 -0
  5. package/apps/api/drizzle/meta/0000_snapshot.json +588 -0
  6. package/apps/api/drizzle/meta/_journal.json +13 -0
  7. package/apps/api/drizzle.config.ts +12 -0
  8. package/apps/api/eslint.config.mjs +35 -0
  9. package/apps/api/nest-cli.json +8 -0
  10. package/apps/api/package.json +99 -0
  11. package/apps/api/src/app.controller.spec.ts +25 -0
  12. package/apps/api/src/app.controller.ts +9 -0
  13. package/apps/api/src/app.module.ts +37 -0
  14. package/apps/api/src/app.service.ts +8 -0
  15. package/apps/api/src/auth/auth.controller.ts +113 -0
  16. package/apps/api/src/auth/auth.module.ts +15 -0
  17. package/apps/api/src/auth/auth.service.ts +309 -0
  18. package/apps/api/src/auth/decorators/current-user-decorator.ts +10 -0
  19. package/apps/api/src/auth/decorators/require-org-role-decorator.ts +7 -0
  20. package/apps/api/src/auth/dto/login.dto.ts +10 -0
  21. package/apps/api/src/auth/dto/register.dto.ts +14 -0
  22. package/apps/api/src/auth/guards/jwt-auth-guard.ts +40 -0
  23. package/apps/api/src/auth/guards/org-role.guards.ts +60 -0
  24. package/apps/api/src/db/db.module.ts +9 -0
  25. package/apps/api/src/db/drizzle.service.ts +16 -0
  26. package/apps/api/src/db/schema/index.ts +6 -0
  27. package/apps/api/src/db/schema/organizations.ts +30 -0
  28. package/apps/api/src/db/schema/projects.ts +26 -0
  29. package/apps/api/src/db/schema/query-history.ts +16 -0
  30. package/apps/api/src/db/schema/storage-buckets.ts +17 -0
  31. package/apps/api/src/db/schema/storage-objects.ts +18 -0
  32. package/apps/api/src/db/schema/users.ts +14 -0
  33. package/apps/api/src/main.ts +51 -0
  34. package/apps/api/src/members/dto/invite-member-dto.ts +6 -0
  35. package/apps/api/src/members/dto/update-role.dto.ts +8 -0
  36. package/apps/api/src/members/invite.service.ts +147 -0
  37. package/apps/api/src/members/members.controller.ts +56 -0
  38. package/apps/api/src/members/members.module.ts +13 -0
  39. package/apps/api/src/members/members.service.ts +85 -0
  40. package/apps/api/src/orgs/dto/create-org.dto.ts +8 -0
  41. package/apps/api/src/orgs/orgs.controller.ts +27 -0
  42. package/apps/api/src/orgs/orgs.module.ts +12 -0
  43. package/apps/api/src/orgs/orgs.service.ts +86 -0
  44. package/apps/api/src/project-api/project-api.controller.ts +95 -0
  45. package/apps/api/src/project-api/project-api.module.ts +12 -0
  46. package/apps/api/src/project-api/project-api.service.ts +196 -0
  47. package/apps/api/src/project-api/project-key.guard.ts +45 -0
  48. package/apps/api/src/project-api/query-parser.ts +129 -0
  49. package/apps/api/src/project-auth/dto/magic-link.dto.ts +7 -0
  50. package/apps/api/src/project-auth/dto/signin.dto.ts +10 -0
  51. package/apps/api/src/project-auth/dto/signup.dto.ts +11 -0
  52. package/apps/api/src/project-auth/project-auth-dashboard.controller.ts +35 -0
  53. package/apps/api/src/project-auth/project-auth.controller.ts +111 -0
  54. package/apps/api/src/project-auth/project-auth.module.ts +13 -0
  55. package/apps/api/src/project-auth/project-auth.service.ts +440 -0
  56. package/apps/api/src/projects/dto/create-project.dto.ts +9 -0
  57. package/apps/api/src/projects/projects.controller.ts +34 -0
  58. package/apps/api/src/projects/projects.module.ts +12 -0
  59. package/apps/api/src/projects/projects.service.ts +136 -0
  60. package/apps/api/src/realtime/realtime-socket.type.ts +14 -0
  61. package/apps/api/src/realtime/realtime.controller.ts +63 -0
  62. package/apps/api/src/realtime/realtime.gateway.ts +150 -0
  63. package/apps/api/src/realtime/realtime.module.ts +14 -0
  64. package/apps/api/src/realtime/realtime.service.ts +101 -0
  65. package/apps/api/src/realtime/trigger.service.ts +95 -0
  66. package/apps/api/src/sql-editor/dto/execute-query.dto.ts +7 -0
  67. package/apps/api/src/sql-editor/sql-editor.controller.ts +28 -0
  68. package/apps/api/src/sql-editor/sql-editor.module.ts +12 -0
  69. package/apps/api/src/sql-editor/sql-editor.service.ts +154 -0
  70. package/apps/api/src/storage/project-storage.controller.ts +140 -0
  71. package/apps/api/src/storage/storage.controller.ts +118 -0
  72. package/apps/api/src/storage/storage.module.ts +15 -0
  73. package/apps/api/src/storage/storage.service.ts +191 -0
  74. package/apps/api/src/storage/uploadthing.ts +23 -0
  75. package/apps/api/src/table-editor/dto/alter-table.dto.ts +15 -0
  76. package/apps/api/src/table-editor/dto/create-table.dto.ts +54 -0
  77. package/apps/api/src/table-editor/table-editor.controller.ts +117 -0
  78. package/apps/api/src/table-editor/table-editor.module.ts +12 -0
  79. package/apps/api/src/table-editor/table-editor.service.ts +366 -0
  80. package/apps/api/test/app.e2e-spec.ts +29 -0
  81. package/apps/api/test/jest-e2e.json +9 -0
  82. package/apps/api/tsconfig.build.json +4 -0
  83. package/apps/api/tsconfig.json +25 -0
  84. package/apps/web/.env.example +12 -0
  85. package/apps/web/AGENTS.md +9 -0
  86. package/apps/web/CLAUDE.md +1 -0
  87. package/apps/web/README.md +36 -0
  88. package/apps/web/components.json +25 -0
  89. package/apps/web/eslint.config.mjs +18 -0
  90. package/apps/web/next.config.ts +11 -0
  91. package/apps/web/package.json +48 -0
  92. package/apps/web/postcss.config.mjs +7 -0
  93. package/apps/web/public/file.svg +1 -0
  94. package/apps/web/public/globe.svg +1 -0
  95. package/apps/web/public/logo.svg +4 -0
  96. package/apps/web/public/next.svg +1 -0
  97. package/apps/web/public/vercel.svg +1 -0
  98. package/apps/web/public/window.svg +1 -0
  99. package/apps/web/src/app/(dashboard)/layout.tsx +33 -0
  100. package/apps/web/src/app/(dashboard)/organizations/(list)/layout.tsx +7 -0
  101. package/apps/web/src/app/(dashboard)/organizations/(list)/new/page.tsx +26 -0
  102. package/apps/web/src/app/(dashboard)/organizations/(list)/page.tsx +44 -0
  103. package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/api/page.tsx +13 -0
  104. package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/auth/layout.tsx +19 -0
  105. package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/auth/page.tsx +10 -0
  106. package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/auth/providers/page.tsx +19 -0
  107. package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/auth/url-configuration/page.tsx +24 -0
  108. package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/auth/users/page.tsx +13 -0
  109. package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/database/page.tsx +29 -0
  110. package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/realtime/page.tsx +28 -0
  111. package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/sql/page.tsx +21 -0
  112. package/apps/web/src/app/(dashboard)/organizations/[slug]/[projectSlug]/storage/page.tsx +28 -0
  113. package/apps/web/src/app/(dashboard)/organizations/[slug]/layout.tsx +20 -0
  114. package/apps/web/src/app/(dashboard)/organizations/[slug]/projects/page.tsx +62 -0
  115. package/apps/web/src/app/(dashboard)/organizations/[slug]/settings/members/page.tsx +28 -0
  116. package/apps/web/src/app/(dashboard)/organizations/[slug]/settings/page.tsx +10 -0
  117. package/apps/web/src/app/(dashboard)/settings/page.tsx +12 -0
  118. package/apps/web/src/app/dashboard/page.tsx +14 -0
  119. package/apps/web/src/app/favicon.ico +0 -0
  120. package/apps/web/src/app/globals.css +115 -0
  121. package/apps/web/src/app/layout.tsx +36 -0
  122. package/apps/web/src/app/login/page.tsx +199 -0
  123. package/apps/web/src/app/page.tsx +100 -0
  124. package/apps/web/src/app/register/page.tsx +218 -0
  125. package/apps/web/src/components/app-sidebar.tsx +301 -0
  126. package/apps/web/src/components/navbar.tsx +63 -0
  127. package/apps/web/src/components/topNav.tsx +68 -0
  128. package/apps/web/src/components/ui/avatar.tsx +112 -0
  129. package/apps/web/src/components/ui/badge.tsx +48 -0
  130. package/apps/web/src/components/ui/button.tsx +66 -0
  131. package/apps/web/src/components/ui/card.tsx +102 -0
  132. package/apps/web/src/components/ui/checkbox.tsx +32 -0
  133. package/apps/web/src/components/ui/dialog.tsx +168 -0
  134. package/apps/web/src/components/ui/dropdown-menu.tsx +269 -0
  135. package/apps/web/src/components/ui/field.tsx +238 -0
  136. package/apps/web/src/components/ui/input.tsx +19 -0
  137. package/apps/web/src/components/ui/label.tsx +24 -0
  138. package/apps/web/src/components/ui/resizable.tsx +50 -0
  139. package/apps/web/src/components/ui/scroll-area.tsx +55 -0
  140. package/apps/web/src/components/ui/select.tsx +192 -0
  141. package/apps/web/src/components/ui/separator.tsx +28 -0
  142. package/apps/web/src/components/ui/sheet.tsx +147 -0
  143. package/apps/web/src/components/ui/sidebar.tsx +703 -0
  144. package/apps/web/src/components/ui/skeleton.tsx +13 -0
  145. package/apps/web/src/components/ui/switch.tsx +33 -0
  146. package/apps/web/src/components/ui/table.tsx +116 -0
  147. package/apps/web/src/components/ui/tooltip.tsx +57 -0
  148. package/apps/web/src/features/api-docs/api-docs-client.tsx +227 -0
  149. package/apps/web/src/features/api-docs/api-docs-helpers.server.ts +86 -0
  150. package/apps/web/src/features/auth/action.ts +67 -0
  151. package/apps/web/src/features/auth/client.schema.ts +13 -0
  152. package/apps/web/src/features/auth/constants.ts +6 -0
  153. package/apps/web/src/features/auth/cookies.ts +86 -0
  154. package/apps/web/src/features/auth/server.schema.ts +11 -0
  155. package/apps/web/src/features/auth/sign-out.ts +17 -0
  156. package/apps/web/src/features/members/action.ts +66 -0
  157. package/apps/web/src/features/members/client.schema.ts +11 -0
  158. package/apps/web/src/features/members/constants.ts +8 -0
  159. package/apps/web/src/features/members/members-client.tsx +198 -0
  160. package/apps/web/src/features/members/members-helpers.server.ts +21 -0
  161. package/apps/web/src/features/members/server.schema.ts +19 -0
  162. package/apps/web/src/features/organization/action.ts +45 -0
  163. package/apps/web/src/features/organization/client.schema.ts +5 -0
  164. package/apps/web/src/features/organization/constants.tsx +3 -0
  165. package/apps/web/src/features/organization/create-org-form.tsx +66 -0
  166. package/apps/web/src/features/organization/organization-helpers.server.ts +35 -0
  167. package/apps/web/src/features/organization/server.schema.ts +10 -0
  168. package/apps/web/src/features/project-auth/project-auth-action.ts +78 -0
  169. package/apps/web/src/features/project-auth/project-auth-client.schema.ts +18 -0
  170. package/apps/web/src/features/project-auth/project-auth-constants.ts +8 -0
  171. package/apps/web/src/features/project-auth/project-auth-helpers.server.ts +39 -0
  172. package/apps/web/src/features/project-auth/project-auth-providers.tsx +222 -0
  173. package/apps/web/src/features/project-auth/project-auth-server.schema.ts +22 -0
  174. package/apps/web/src/features/project-auth/project-auth-shell.tsx +106 -0
  175. package/apps/web/src/features/project-auth/project-auth-url-config.tsx +151 -0
  176. package/apps/web/src/features/project-auth/project-auth-users.tsx +138 -0
  177. package/apps/web/src/features/projects/action.ts +46 -0
  178. package/apps/web/src/features/projects/client.schema.ts +9 -0
  179. package/apps/web/src/features/projects/constants.ts +6 -0
  180. package/apps/web/src/features/projects/create-project-modal.tsx +117 -0
  181. package/apps/web/src/features/projects/project-helpers.server.ts +38 -0
  182. package/apps/web/src/features/projects/server.schema.ts +10 -0
  183. package/apps/web/src/features/realtime/realtime-client.tsx +294 -0
  184. package/apps/web/src/features/realtime/realtime-helpers.server.ts +40 -0
  185. package/apps/web/src/features/sql-editor/sql-editor-client.tsx +324 -0
  186. package/apps/web/src/features/sql-editor/sql-editor-helpers.server.ts +22 -0
  187. package/apps/web/src/features/storage/storage-client.tsx +500 -0
  188. package/apps/web/src/features/storage/storage-helpers.server.ts +36 -0
  189. package/apps/web/src/features/table-editor/action.ts +97 -0
  190. package/apps/web/src/features/table-editor/add-column-dialog.tsx +184 -0
  191. package/apps/web/src/features/table-editor/client.schema.ts +36 -0
  192. package/apps/web/src/features/table-editor/create-table-drawer.tsx +542 -0
  193. package/apps/web/src/features/table-editor/server.schema.ts +41 -0
  194. package/apps/web/src/features/table-editor/table-editor-client.tsx +737 -0
  195. package/apps/web/src/features/table-editor/table-editor-helpers.server.ts +39 -0
  196. package/apps/web/src/hooks/use-mobile.ts +19 -0
  197. package/apps/web/src/lib/axios.ts +8 -0
  198. package/apps/web/src/lib/utils.ts +6 -0
  199. package/apps/web/src/proxy.ts +72 -0
  200. package/apps/web/src/server-utils/utils.ts +10 -0
  201. package/apps/web/tsconfig.json +34 -0
  202. package/package.json +22 -0
  203. package/packages/apiDatabase-js/package.json +39 -0
  204. package/packages/apiDatabase-js/src/auth/index.ts +121 -0
  205. package/packages/apiDatabase-js/src/client.ts +25 -0
  206. package/packages/apiDatabase-js/src/db/index.ts +15 -0
  207. package/packages/apiDatabase-js/src/db/query-builder.ts +194 -0
  208. package/packages/apiDatabase-js/src/index.ts +16 -0
  209. package/packages/apiDatabase-js/src/realtime/index.ts +84 -0
  210. package/packages/apiDatabase-js/src/storage/index.ts +130 -0
  211. package/packages/apiDatabase-js/tsconfig.json +13 -0
  212. package/packages/apiDatabase-js/tsdown.config.ts +20 -0
  213. package/packages/constants/package.json +6 -0
  214. package/packages/constants/src/index.ts +76 -0
  215. package/packages/tsconfig.json +13 -0
  216. package/packages/tsdown.config.ts +20 -0
  217. package/packages/types/package.json +6 -0
  218. package/packages/types/src/index.ts +254 -0
  219. package/pnpm-workspace.yaml +10 -0
@@ -0,0 +1,136 @@
1
+ import { Injectable, NotFoundException } from '@nestjs/common';
2
+ import { and, eq, isNull } from 'drizzle-orm';
3
+ import { randomBytes } from 'crypto';
4
+ import slugify from 'slugify';
5
+ import { JwtService } from '@nestjs/jwt';
6
+ import { ConfigService } from '@nestjs/config';
7
+ import { DrizzleService } from '../db/drizzle.service';
8
+ import { projects, organizations, orgMembers } from '../db/schema';
9
+ import { CreateProjectDto } from './dto/create-project.dto';
10
+ import { PROJECT_KEY_ROLES } from '@apiDatabase/constants';
11
+
12
+ @Injectable()
13
+ export class ProjectsService {
14
+ constructor(
15
+ private drizzle: DrizzleService,
16
+ private jwtService: JwtService,
17
+ private configService: ConfigService,
18
+ ) {}
19
+
20
+ // Utils
21
+ private generateProjectSlug(name: string): string {
22
+ const base = slugify(name, { lower: true, strict: true });
23
+ const suffix = randomBytes(3).toString('hex');
24
+ return `${base}-${suffix}`;
25
+ }
26
+
27
+ private generateDbSchema(): string {
28
+ return `proj_${randomBytes(4).toString('hex')}`;
29
+ }
30
+
31
+ private signProjectKey(projectId: string, role: string): string {
32
+ return this.jwtService.sign(
33
+ { projectId, role },
34
+ {
35
+ secret: this.configService.get<string>('PROJECT_JWT_SECRET'),
36
+ // project keys don't expire — they're rotated manually if compromised
37
+ expiresIn: '100y',
38
+ },
39
+ );
40
+ }
41
+
42
+ private async provisionSchema(dbSchema: string): Promise<void> {
43
+ await this.drizzle.db.execute(`CREATE SCHEMA IF NOT EXISTS "${dbSchema}"`);
44
+ }
45
+
46
+ // Queries
47
+ async getProjectsForOrg(orgSlug: string, userId: string) {
48
+ return this.drizzle.db
49
+ .select({
50
+ id: projects.id,
51
+ name: projects.name,
52
+ slug: projects.slug,
53
+ projectUrl: projects.projectUrl,
54
+ createdAt: projects.createdAt,
55
+ updatedAt: projects.updatedAt,
56
+ })
57
+ .from(projects)
58
+ .innerJoin(organizations, eq(projects.orgId, organizations.id))
59
+ .innerJoin(
60
+ orgMembers,
61
+ and(
62
+ eq(orgMembers.orgId, organizations.id),
63
+ eq(orgMembers.userId, userId),
64
+ isNull(orgMembers.removedAt),
65
+ ),
66
+ )
67
+ .where(eq(organizations.slug, orgSlug));
68
+ }
69
+
70
+ async getProjectBySlug(orgSlug: string, projectSlug: string, userId: string) {
71
+ const [row] = await this.drizzle.db
72
+ .select()
73
+ .from(projects)
74
+ .innerJoin(organizations, eq(projects.orgId, organizations.id))
75
+ .innerJoin(
76
+ orgMembers,
77
+ and(
78
+ eq(orgMembers.orgId, organizations.id),
79
+ eq(orgMembers.userId, userId),
80
+ isNull(orgMembers.removedAt),
81
+ ),
82
+ )
83
+ .where(
84
+ and(eq(organizations.slug, orgSlug), eq(projects.slug, projectSlug)),
85
+ )
86
+ .limit(1);
87
+
88
+ if (!row) throw new NotFoundException('Project not found');
89
+ return row;
90
+ }
91
+
92
+ async createProject(orgSlug: string, dto: CreateProjectDto) {
93
+ const [org] = await this.drizzle.db
94
+ .select()
95
+ .from(organizations)
96
+ .where(eq(organizations.slug, orgSlug))
97
+ .limit(1);
98
+
99
+ if (!org) throw new NotFoundException('Organization not found');
100
+
101
+ const projectSlug = this.generateProjectSlug(dto.name);
102
+ const dbSchema = this.generateDbSchema();
103
+ const projectUrl = `${this.configService.get<string>('API_URL')}/projects/${projectSlug}`;
104
+
105
+ await this.provisionSchema(dbSchema);
106
+ const authJwtSecret = randomBytes(32).toString('hex');
107
+
108
+ const [project] = await this.drizzle.db
109
+ .insert(projects)
110
+ .values({
111
+ orgId: org.id,
112
+ name: dto.name,
113
+ slug: projectSlug,
114
+ dbSchema,
115
+ projectUrl,
116
+ anonKey: '',
117
+ serviceRoleKey: '',
118
+ authJwtSecret,
119
+ })
120
+ .returning();
121
+
122
+ const anonKey = this.signProjectKey(project.id, PROJECT_KEY_ROLES.ANON);
123
+ const serviceRoleKey = this.signProjectKey(
124
+ project.id,
125
+ PROJECT_KEY_ROLES.SERVICE_ROLE,
126
+ );
127
+
128
+ const [updated] = await this.drizzle.db
129
+ .update(projects)
130
+ .set({ anonKey, serviceRoleKey })
131
+ .where(eq(projects.id, project.id))
132
+ .returning();
133
+
134
+ return updated;
135
+ }
136
+ }
@@ -0,0 +1,14 @@
1
+ import type { DefaultEventsMap, Socket } from 'socket.io';
2
+ import type { PROJECT_KEY_ROLES } from '@apiDatabase/constants';
3
+
4
+ export interface RealtimeSocketData {
5
+ projectId?: string;
6
+ role?: (typeof PROJECT_KEY_ROLES)[keyof typeof PROJECT_KEY_ROLES];
7
+ }
8
+
9
+ export type RealtimeSocket = Socket<
10
+ DefaultEventsMap,
11
+ DefaultEventsMap,
12
+ DefaultEventsMap,
13
+ RealtimeSocketData
14
+ >;
@@ -0,0 +1,63 @@
1
+ import {
2
+ Controller,
3
+ Delete,
4
+ NotFoundException,
5
+ Param,
6
+ Post,
7
+ UseGuards,
8
+ } from '@nestjs/common';
9
+ import { and, eq } from 'drizzle-orm';
10
+ import { TriggerService } from './trigger.service';
11
+ import { DrizzleService } from '../db/drizzle.service';
12
+ import { JwtAuthGuard } from '../auth/guards/jwt-auth-guard';
13
+ import { OrgRoleGuard } from '../auth/guards/org-role.guards';
14
+ import { projects, organizations } from '../db/schema';
15
+
16
+ @Controller('orgs/:slug/projects/:projectSlug/realtime')
17
+ @UseGuards(JwtAuthGuard, OrgRoleGuard)
18
+ export class RealtimeController {
19
+ constructor(
20
+ private triggerService: TriggerService,
21
+ private drizzle: DrizzleService,
22
+ ) {}
23
+
24
+ private async getProject(orgSlug: string, projectSlug: string) {
25
+ const [row] = await this.drizzle.db
26
+ .select({ id: projects.id, dbSchema: projects.dbSchema })
27
+ .from(projects)
28
+ .innerJoin(organizations, eq(projects.orgId, organizations.id))
29
+ .where(
30
+ and(eq(organizations.slug, orgSlug), eq(projects.slug, projectSlug)),
31
+ )
32
+ .limit(1);
33
+
34
+ if (!row) throw new NotFoundException('Project not found');
35
+ return row;
36
+ }
37
+
38
+ @Post(':tableName/enable')
39
+ async enableRealtime(
40
+ @Param('slug') slug: string,
41
+ @Param('projectSlug') projectSlug: string,
42
+ @Param('tableName') tableName: string,
43
+ ) {
44
+ const project = await this.getProject(slug, projectSlug);
45
+ await this.triggerService.enableRealtime(
46
+ project.dbSchema,
47
+ project.id,
48
+ tableName,
49
+ );
50
+ return { message: `Realtime enabled for ${tableName}` };
51
+ }
52
+
53
+ @Delete(':tableName/disable')
54
+ async disableRealtime(
55
+ @Param('slug') slug: string,
56
+ @Param('projectSlug') projectSlug: string,
57
+ @Param('tableName') tableName: string,
58
+ ) {
59
+ const project = await this.getProject(slug, projectSlug);
60
+ await this.triggerService.disableRealtime(project.dbSchema, tableName);
61
+ return { message: `Realtime disabled for ${tableName}` };
62
+ }
63
+ }
@@ -0,0 +1,150 @@
1
+ import {
2
+ ConnectedSocket,
3
+ MessageBody,
4
+ OnGatewayConnection,
5
+ OnGatewayDisconnect,
6
+ SubscribeMessage,
7
+ WebSocketGateway,
8
+ WebSocketServer,
9
+ } from '@nestjs/websockets';
10
+ import { Server, Socket } from 'socket.io';
11
+ import { JwtService } from '@nestjs/jwt';
12
+ import { ConfigService } from '@nestjs/config';
13
+ import { RealtimeService } from './realtime.service';
14
+ import { REALTIME_EVENTS, PROJECT_KEY_ROLES } from '@apiDatabase/constants';
15
+ import type { RealtimeEvent } from '@apiDatabase/types';
16
+ import type { ProjectKeyPayload } from '../project-api/project-key.guard';
17
+ import type { RealtimeSocket } from './realtime-socket.type';
18
+
19
+ type SocketCallbackEntry = {
20
+ projectId: string;
21
+ tableName: string;
22
+ callback: (event: RealtimeEvent) => void;
23
+ };
24
+
25
+ @WebSocketGateway({
26
+ namespace: '/realtime',
27
+ cors: {
28
+ origin: process.env.WEB_URL ?? 'http://localhost:3001',
29
+ credentials: true,
30
+ },
31
+ })
32
+ export class RealtimeGateway
33
+ implements OnGatewayConnection, OnGatewayDisconnect
34
+ {
35
+ @WebSocketServer()
36
+ server!: Server;
37
+
38
+ private socketCallbacks = new Map<string, SocketCallbackEntry[]>();
39
+
40
+ constructor(
41
+ private realtimeService: RealtimeService,
42
+ private jwtService: JwtService,
43
+ private configService: ConfigService,
44
+ ) {}
45
+
46
+ async handleConnection(client: RealtimeSocket) {
47
+ const token = client.handshake.auth['token'] as string | undefined;
48
+
49
+ if (!token) {
50
+ client.emit(REALTIME_EVENTS.ERROR, 'Missing API key');
51
+ client.disconnect();
52
+ return;
53
+ }
54
+
55
+ try {
56
+ const payload = this.jwtService.verify<ProjectKeyPayload>(token, {
57
+ secret: this.configService.get<string>('PROJECT_JWT_SECRET'),
58
+ });
59
+
60
+ if (
61
+ payload.role !== PROJECT_KEY_ROLES.ANON &&
62
+ payload.role !== PROJECT_KEY_ROLES.SERVICE_ROLE
63
+ ) {
64
+ throw new Error('Invalid key role');
65
+ }
66
+
67
+ client.data.projectId = payload.projectId;
68
+ client.data.role = payload.role;
69
+ await client.join(`project:${payload.projectId}`);
70
+ } catch {
71
+ client.emit(REALTIME_EVENTS.ERROR, 'Invalid API key');
72
+ client.disconnect();
73
+ }
74
+ }
75
+
76
+ handleDisconnect(client: Socket) {
77
+ const callbacks = this.socketCallbacks.get(client.id) ?? [];
78
+ for (const { projectId, tableName, callback } of callbacks) {
79
+ this.realtimeService.unsubscribe(projectId, tableName, callback);
80
+ }
81
+ this.socketCallbacks.delete(client.id);
82
+ }
83
+
84
+ @SubscribeMessage(REALTIME_EVENTS.SUBSCRIBE)
85
+ async handleSubscribe(
86
+ @ConnectedSocket() client: RealtimeSocket,
87
+ @MessageBody() tableName: string,
88
+ ) {
89
+ const projectId = client.data.projectId;
90
+ if (!projectId || !tableName?.trim()) return;
91
+
92
+ const normalizedTable = tableName.trim();
93
+ const room = `project:${projectId}:table:${normalizedTable}`;
94
+
95
+ const existing = this.socketCallbacks.get(client.id) ?? [];
96
+ if (
97
+ existing.some(
98
+ (entry) =>
99
+ entry.projectId === projectId && entry.tableName === normalizedTable,
100
+ )
101
+ ) {
102
+ return;
103
+ }
104
+
105
+ await client.join(room);
106
+
107
+ const callback = (event: RealtimeEvent) => {
108
+ this.server.to(room).emit(REALTIME_EVENTS.EVENT, event);
109
+ };
110
+
111
+ await this.realtimeService.subscribe(projectId, normalizedTable, callback);
112
+
113
+ existing.push({
114
+ projectId,
115
+ tableName: normalizedTable,
116
+ callback,
117
+ });
118
+ this.socketCallbacks.set(client.id, existing);
119
+ }
120
+
121
+ @SubscribeMessage(REALTIME_EVENTS.UNSUBSCRIBE)
122
+ async handleUnsubscribe(
123
+ @ConnectedSocket() client: RealtimeSocket,
124
+ @MessageBody() tableName: string,
125
+ ) {
126
+ const projectId = client.data.projectId;
127
+ if (!projectId || !tableName?.trim()) return;
128
+
129
+ const normalizedTable = tableName.trim();
130
+ const room = `project:${projectId}:table:${normalizedTable}`;
131
+ await client.leave(room);
132
+
133
+ const callbacks = this.socketCallbacks.get(client.id) ?? [];
134
+ const entry = callbacks.find(
135
+ (c) => c.projectId === projectId && c.tableName === normalizedTable,
136
+ );
137
+
138
+ if (entry) {
139
+ this.realtimeService.unsubscribe(
140
+ projectId,
141
+ normalizedTable,
142
+ entry.callback,
143
+ );
144
+ this.socketCallbacks.set(
145
+ client.id,
146
+ callbacks.filter((c) => c !== entry),
147
+ );
148
+ }
149
+ }
150
+ }
@@ -0,0 +1,14 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { RealtimeGateway } from './realtime.gateway';
3
+ import { RealtimeService } from './realtime.service';
4
+ import { RealtimeController } from './realtime.controller';
5
+ import { TriggerService } from './trigger.service';
6
+ import { AuthModule } from '../auth/auth.module';
7
+ import { OrgRoleGuard } from '../auth/guards/org-role.guards';
8
+
9
+ @Module({
10
+ imports: [AuthModule],
11
+ providers: [RealtimeGateway, RealtimeService, TriggerService, OrgRoleGuard],
12
+ controllers: [RealtimeController],
13
+ })
14
+ export class RealtimeModule {}
@@ -0,0 +1,101 @@
1
+ import { Injectable, OnModuleDestroy } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { Client, neonConfig } from '@neondatabase/serverless';
4
+ import { TriggerService } from './trigger.service';
5
+ import type { RealtimeEvent } from '@apiDatabase/types';
6
+
7
+ neonConfig.webSocketConstructor = WebSocket;
8
+
9
+ type NotifyCallback = (event: RealtimeEvent) => void;
10
+
11
+ @Injectable()
12
+ export class RealtimeService implements OnModuleDestroy {
13
+ private listeners = new Map<
14
+ string,
15
+ {
16
+ client: Client;
17
+ callbacks: Set<NotifyCallback>;
18
+ }
19
+ >();
20
+
21
+ constructor(private configService: ConfigService) {}
22
+
23
+ // Neon LISTEN/NOTIFY requires a direct (non-pooler) connection
24
+ private getListenConnectionString(): string {
25
+ const realtime = this.configService.get<string>('REALTIME_DATABASE_URL');
26
+ const database = this.configService.get<string>('DATABASE_URL');
27
+ const url = realtime ?? database;
28
+ if (!url) {
29
+ throw new Error('DATABASE_URL is not configured');
30
+ }
31
+ let clean = url.replace('-pooler', '').trim();
32
+ clean = clean.replace(/([?&])(sslmode|channel_binding)=[^&]*/g, '$1');
33
+ clean = clean.replace(/[?&]$/, '').replace(/\?&/, '?');
34
+ return clean;
35
+ }
36
+
37
+ async subscribe(
38
+ projectId: string,
39
+ tableName: string,
40
+ callback: NotifyCallback,
41
+ ): Promise<void> {
42
+ const channel = TriggerService.channelName(projectId, tableName);
43
+
44
+ const existing = this.listeners.get(channel);
45
+ if (existing) {
46
+ existing.callbacks.add(callback);
47
+ return;
48
+ }
49
+
50
+ const client = new Client({
51
+ connectionString: this.getListenConnectionString(),
52
+ });
53
+
54
+ await client.connect();
55
+
56
+ client.on('notification', (msg) => {
57
+ if (!msg.payload) return;
58
+ try {
59
+ const event = JSON.parse(msg.payload) as RealtimeEvent;
60
+ const entry = this.listeners.get(channel);
61
+ entry?.callbacks.forEach((cb) => cb(event));
62
+ } catch {
63
+ // malformed payload — ignore
64
+ }
65
+ });
66
+
67
+ // channel names are lowercase identifiers — no double quotes needed
68
+ await client.query(`LISTEN ${channel}`);
69
+
70
+ this.listeners.set(channel, {
71
+ client,
72
+ callbacks: new Set([callback]),
73
+ });
74
+ }
75
+
76
+ unsubscribe(
77
+ projectId: string,
78
+ tableName: string,
79
+ callback: NotifyCallback,
80
+ ): void {
81
+ const channel = TriggerService.channelName(projectId, tableName);
82
+ const entry = this.listeners.get(channel);
83
+ if (!entry) return;
84
+
85
+ entry.callbacks.delete(callback);
86
+
87
+ if (entry.callbacks.size === 0) {
88
+ void entry.client.query(`UNLISTEN ${channel}`).finally(() => {
89
+ void entry.client.end();
90
+ });
91
+ this.listeners.delete(channel);
92
+ }
93
+ }
94
+
95
+ async onModuleDestroy() {
96
+ await Promise.all(
97
+ [...this.listeners.values()].map(({ client }) => client.end()),
98
+ );
99
+ this.listeners.clear();
100
+ }
101
+ }
@@ -0,0 +1,95 @@
1
+ import { BadRequestException, Injectable } from '@nestjs/common';
2
+ import { DrizzleService } from '../db/drizzle.service';
3
+
4
+ @Injectable()
5
+ export class TriggerService {
6
+ constructor(private drizzle: DrizzleService) {}
7
+
8
+ private assertSafeIdentifier(name: string, label: string): void {
9
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
10
+ throw new BadRequestException(`Invalid ${label}: ${name}`);
11
+ }
12
+ }
13
+
14
+ static channelName(projectId: string, tableName: string): string {
15
+ return `project_${projectId.replace(/-/g, '_')}_${tableName}`;
16
+ }
17
+
18
+ async enableRealtime(
19
+ dbSchema: string,
20
+ projectId: string,
21
+ tableName: string,
22
+ ): Promise<void> {
23
+ this.assertSafeIdentifier(dbSchema, 'schema name');
24
+ this.assertSafeIdentifier(tableName, 'table name');
25
+
26
+ const channel = TriggerService.channelName(projectId, tableName);
27
+ const fnName = `${tableName}_notify`;
28
+
29
+ await this.drizzle.db.execute(`
30
+ CREATE OR REPLACE FUNCTION "${dbSchema}"."${fnName}"()
31
+ RETURNS TRIGGER AS $$
32
+ DECLARE
33
+ payload JSON;
34
+ BEGIN
35
+ IF TG_OP = 'DELETE' THEN
36
+ payload = json_build_object(
37
+ 'type', TG_OP,
38
+ 'table', TG_TABLE_NAME,
39
+ 'record', row_to_json(OLD),
40
+ 'oldRecord', row_to_json(OLD),
41
+ 'projectId', '${projectId}',
42
+ 'timestamp', now()::text
43
+ );
44
+ PERFORM pg_notify('${channel}', payload::text);
45
+ RETURN OLD;
46
+ ELSIF TG_OP = 'UPDATE' THEN
47
+ payload = json_build_object(
48
+ 'type', TG_OP,
49
+ 'table', TG_TABLE_NAME,
50
+ 'record', row_to_json(NEW),
51
+ 'oldRecord', row_to_json(OLD),
52
+ 'projectId', '${projectId}',
53
+ 'timestamp', now()::text
54
+ );
55
+ PERFORM pg_notify('${channel}', payload::text);
56
+ RETURN NEW;
57
+ ELSE
58
+ payload = json_build_object(
59
+ 'type', TG_OP,
60
+ 'table', TG_TABLE_NAME,
61
+ 'record', row_to_json(NEW),
62
+ 'projectId', '${projectId}',
63
+ 'timestamp', now()::text
64
+ );
65
+ PERFORM pg_notify('${channel}', payload::text);
66
+ RETURN NEW;
67
+ END IF;
68
+ END;
69
+ $$ LANGUAGE plpgsql;
70
+ `);
71
+
72
+ await this.drizzle.db.execute(`
73
+ DROP TRIGGER IF EXISTS "${tableName}_realtime_trigger"
74
+ ON "${dbSchema}"."${tableName}";
75
+ `);
76
+
77
+ await this.drizzle.db.execute(`
78
+ CREATE TRIGGER "${tableName}_realtime_trigger"
79
+ AFTER INSERT OR UPDATE OR DELETE
80
+ ON "${dbSchema}"."${tableName}"
81
+ FOR EACH ROW
82
+ EXECUTE FUNCTION "${dbSchema}"."${fnName}"();
83
+ `);
84
+ }
85
+
86
+ async disableRealtime(dbSchema: string, tableName: string): Promise<void> {
87
+ this.assertSafeIdentifier(dbSchema, 'schema name');
88
+ this.assertSafeIdentifier(tableName, 'table name');
89
+
90
+ await this.drizzle.db.execute(`
91
+ DROP TRIGGER IF EXISTS "${tableName}_realtime_trigger"
92
+ ON "${dbSchema}"."${tableName}";
93
+ `);
94
+ }
95
+ }
@@ -0,0 +1,7 @@
1
+ import { IsString, MinLength } from 'class-validator';
2
+
3
+ export class ExecuteQueryDto {
4
+ @IsString()
5
+ @MinLength(1)
6
+ sql!: string;
7
+ }
@@ -0,0 +1,28 @@
1
+ import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
2
+ import { SqlEditorService } from './sql-editor.service';
3
+ import { ExecuteQueryDto } from './dto/execute-query.dto';
4
+ import { JwtAuthGuard } from '../auth/guards/jwt-auth-guard';
5
+ import { OrgRoleGuard } from '../auth/guards/org-role.guards';
6
+
7
+ @Controller('orgs/:slug/projects/:projectSlug/sql')
8
+ @UseGuards(JwtAuthGuard, OrgRoleGuard)
9
+ export class SqlEditorController {
10
+ constructor(private sqlEditorService: SqlEditorService) {}
11
+
12
+ @Post()
13
+ executeQuery(
14
+ @Param('slug') slug: string,
15
+ @Param('projectSlug') projectSlug: string,
16
+ @Body() dto: ExecuteQueryDto,
17
+ ) {
18
+ return this.sqlEditorService.executeQuery(slug, projectSlug, dto.sql);
19
+ }
20
+
21
+ @Get('history')
22
+ getHistory(
23
+ @Param('slug') slug: string,
24
+ @Param('projectSlug') projectSlug: string,
25
+ ) {
26
+ return this.sqlEditorService.getHistory(slug, projectSlug);
27
+ }
28
+ }
@@ -0,0 +1,12 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { SqlEditorService } from './sql-editor.service';
3
+ import { SqlEditorController } from './sql-editor.controller';
4
+ import { AuthModule } from '../auth/auth.module';
5
+ import { OrgRoleGuard } from '../auth/guards/org-role.guards';
6
+
7
+ @Module({
8
+ imports: [AuthModule],
9
+ providers: [SqlEditorService, OrgRoleGuard],
10
+ controllers: [SqlEditorController],
11
+ })
12
+ export class SqlEditorModule {}