@prmichaelsen/acp-visualizer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/agent/commands/acp.clarification-address.md +417 -0
- package/agent/commands/acp.clarification-capture.md +386 -0
- package/agent/commands/acp.clarification-create.md +437 -0
- package/agent/commands/acp.clarifications-research.md +326 -0
- package/agent/commands/acp.command-create.md +432 -0
- package/agent/commands/acp.design-create.md +286 -0
- package/agent/commands/acp.design-reference.md +355 -0
- package/agent/commands/acp.handoff.md +270 -0
- package/agent/commands/acp.index.md +423 -0
- package/agent/commands/acp.init.md +546 -0
- package/agent/commands/acp.package-create.md +895 -0
- package/agent/commands/acp.package-info.md +212 -0
- package/agent/commands/acp.package-install.md +539 -0
- package/agent/commands/acp.package-list.md +280 -0
- package/agent/commands/acp.package-publish.md +541 -0
- package/agent/commands/acp.package-remove.md +293 -0
- package/agent/commands/acp.package-search.md +307 -0
- package/agent/commands/acp.package-update.md +361 -0
- package/agent/commands/acp.package-validate.md +540 -0
- package/agent/commands/acp.pattern-create.md +386 -0
- package/agent/commands/acp.plan.md +587 -0
- package/agent/commands/acp.proceed.md +882 -0
- package/agent/commands/acp.project-create.md +675 -0
- package/agent/commands/acp.project-info.md +312 -0
- package/agent/commands/acp.project-list.md +226 -0
- package/agent/commands/acp.project-remove.md +379 -0
- package/agent/commands/acp.project-set.md +227 -0
- package/agent/commands/acp.project-update.md +307 -0
- package/agent/commands/acp.projects-restore.md +228 -0
- package/agent/commands/acp.projects-sync.md +347 -0
- package/agent/commands/acp.report.md +407 -0
- package/agent/commands/acp.resume.md +239 -0
- package/agent/commands/acp.sessions.md +301 -0
- package/agent/commands/acp.status.md +293 -0
- package/agent/commands/acp.sync.md +364 -0
- package/agent/commands/acp.task-create.md +500 -0
- package/agent/commands/acp.update.md +302 -0
- package/agent/commands/acp.validate.md +466 -0
- package/agent/commands/acp.version-check-for-updates.md +276 -0
- package/agent/commands/acp.version-check.md +191 -0
- package/agent/commands/acp.version-update.md +289 -0
- package/agent/commands/command.template.md +339 -0
- package/agent/commands/git.commit.md +526 -0
- package/agent/commands/git.init.md +514 -0
- package/agent/commands/tanstack-cloudflare.deploy.md +272 -0
- package/agent/commands/tanstack-cloudflare.tail.md +275 -0
- package/agent/design/.gitkeep +0 -0
- package/agent/design/design.template.md +154 -0
- package/agent/design/local.dashboard-layout-routing.md +288 -0
- package/agent/design/local.data-model-yaml-parsing.md +310 -0
- package/agent/design/local.search-filtering.md +331 -0
- package/agent/design/local.server-api-auto-refresh.md +235 -0
- package/agent/design/local.table-tree-views.md +299 -0
- package/agent/design/local.visualizer-requirements.md +349 -0
- package/agent/design/requirements.template.md +387 -0
- package/agent/index/.gitkeep +0 -0
- package/agent/index/acp.core.yaml +137 -0
- package/agent/index/local.main.template.yaml +37 -0
- package/agent/manifest.template.yaml +13 -0
- package/agent/manifest.yaml +302 -0
- package/agent/milestones/.gitkeep +0 -0
- package/agent/milestones/milestone-1-project-scaffold-data-pipeline.md +67 -0
- package/agent/milestones/milestone-1-{title}.template.md +206 -0
- package/agent/milestones/milestone-2-dashboard-views-interaction.md +79 -0
- package/agent/package.template.yaml +86 -0
- package/agent/patterns/.gitkeep +0 -0
- package/agent/patterns/bootstrap.template.md +1237 -0
- package/agent/patterns/pattern.template.md +382 -0
- package/agent/patterns/tanstack-cloudflare.acl-permissions.md +332 -0
- package/agent/patterns/tanstack-cloudflare.action-bar-item.md +416 -0
- package/agent/patterns/tanstack-cloudflare.api-route-handlers.md +401 -0
- package/agent/patterns/tanstack-cloudflare.auth-session-management.md +387 -0
- package/agent/patterns/tanstack-cloudflare.card-and-list.md +271 -0
- package/agent/patterns/tanstack-cloudflare.chat-engine.md +353 -0
- package/agent/patterns/tanstack-cloudflare.confirmation-tokens.md +346 -0
- package/agent/patterns/tanstack-cloudflare.durable-objects-websocket.md +516 -0
- package/agent/patterns/tanstack-cloudflare.email-service.md +431 -0
- package/agent/patterns/tanstack-cloudflare.expander.md +98 -0
- package/agent/patterns/tanstack-cloudflare.fcm-push.md +115 -0
- package/agent/patterns/tanstack-cloudflare.firebase-anonymous-sessions.md +441 -0
- package/agent/patterns/tanstack-cloudflare.firebase-auth.md +348 -0
- package/agent/patterns/tanstack-cloudflare.firebase-firestore.md +550 -0
- package/agent/patterns/tanstack-cloudflare.firebase-storage.md +369 -0
- package/agent/patterns/tanstack-cloudflare.form-controls.md +145 -0
- package/agent/patterns/tanstack-cloudflare.global-search-context.md +93 -0
- package/agent/patterns/tanstack-cloudflare.image-carousel.md +126 -0
- package/agent/patterns/tanstack-cloudflare.library-services.md +553 -0
- package/agent/patterns/tanstack-cloudflare.lightbox.md +169 -0
- package/agent/patterns/tanstack-cloudflare.markdown-content.md +115 -0
- package/agent/patterns/tanstack-cloudflare.mention-suggestions.md +98 -0
- package/agent/patterns/tanstack-cloudflare.modal.md +156 -0
- package/agent/patterns/tanstack-cloudflare.nextjs-to-tanstack-routing.md +461 -0
- package/agent/patterns/tanstack-cloudflare.notifications-engine.md +151 -0
- package/agent/patterns/tanstack-cloudflare.oauth-token-refresh.md +90 -0
- package/agent/patterns/tanstack-cloudflare.og-metadata.md +296 -0
- package/agent/patterns/tanstack-cloudflare.pagination.md +442 -0
- package/agent/patterns/tanstack-cloudflare.pill-input.md +220 -0
- package/agent/patterns/tanstack-cloudflare.provider-adapter.md +401 -0
- package/agent/patterns/tanstack-cloudflare.rate-limiting.md +323 -0
- package/agent/patterns/tanstack-cloudflare.scheduled-tasks.md +338 -0
- package/agent/patterns/tanstack-cloudflare.searchable-settings.md +375 -0
- package/agent/patterns/tanstack-cloudflare.slide-over.md +129 -0
- package/agent/patterns/tanstack-cloudflare.ssr-preload.md +571 -0
- package/agent/patterns/tanstack-cloudflare.third-party-api-integration.md +508 -0
- package/agent/patterns/tanstack-cloudflare.toast-system.md +142 -0
- package/agent/patterns/tanstack-cloudflare.unified-header.md +280 -0
- package/agent/patterns/tanstack-cloudflare.user-scoped-collections.md +628 -0
- package/agent/patterns/tanstack-cloudflare.websocket-manager.md +237 -0
- package/agent/patterns/tanstack-cloudflare.wrangler-configuration.md +358 -0
- package/agent/patterns/tanstack-cloudflare.zod-schema-validation.md +336 -0
- package/agent/progress.template.yaml +161 -0
- package/agent/progress.yaml +145 -0
- package/agent/schemas/package.schema.yaml +276 -0
- package/agent/scripts/acp.common.sh +1781 -0
- package/agent/scripts/acp.install.sh +333 -0
- package/agent/scripts/acp.package-create.sh +924 -0
- package/agent/scripts/acp.package-info.sh +288 -0
- package/agent/scripts/acp.package-install.sh +893 -0
- package/agent/scripts/acp.package-list.sh +311 -0
- package/agent/scripts/acp.package-publish.sh +420 -0
- package/agent/scripts/acp.package-remove.sh +348 -0
- package/agent/scripts/acp.package-search.sh +156 -0
- package/agent/scripts/acp.package-update.sh +517 -0
- package/agent/scripts/acp.package-validate.sh +1018 -0
- package/agent/scripts/acp.uninstall.sh +85 -0
- package/agent/scripts/acp.version-check-for-updates.sh +98 -0
- package/agent/scripts/acp.version-check.sh +47 -0
- package/agent/scripts/acp.version-update.sh +176 -0
- package/agent/scripts/acp.yaml-parser.sh +985 -0
- package/agent/scripts/acp.yaml-validate.sh +205 -0
- package/agent/tasks/.gitkeep +0 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-1-initialize-tanstack-start-project.md +210 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-2-implement-data-model-yaml-parser.md +294 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-3-build-server-api-data-loading.md +193 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-4-add-auto-refresh-sse.md +262 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-10-polish-integration-testing.md +156 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-5-build-dashboard-layout-routing.md +178 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-6-build-overview-page.md +141 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-7-implement-milestone-table-view.md +153 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-8-implement-milestone-tree-view.md +174 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-9-implement-search-filtering.md +233 -0
- package/agent/tasks/task-1-{title}.template.md +244 -0
- package/bin/visualize.mjs +84 -0
- package/package.json +48 -0
- package/src/components/ExtraFieldsBadge.tsx +15 -0
- package/src/components/FilterBar.tsx +33 -0
- package/src/components/Header.tsx +23 -0
- package/src/components/MilestoneTable.tsx +167 -0
- package/src/components/MilestoneTree.tsx +84 -0
- package/src/components/ProgressBar.tsx +20 -0
- package/src/components/SearchInput.tsx +22 -0
- package/src/components/Sidebar.tsx +54 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/StatusDot.tsx +12 -0
- package/src/components/TaskList.tsx +36 -0
- package/src/components/ViewToggle.tsx +31 -0
- package/src/lib/config.ts +8 -0
- package/src/lib/file-watcher.ts +43 -0
- package/src/lib/search.ts +48 -0
- package/src/lib/types.ts +73 -0
- package/src/lib/useAutoRefresh.ts +31 -0
- package/src/lib/useCollapse.ts +31 -0
- package/src/lib/useFilteredData.ts +55 -0
- package/src/lib/yaml-loader-real.spec.ts +47 -0
- package/src/lib/yaml-loader.spec.ts +201 -0
- package/src/lib/yaml-loader.ts +265 -0
- package/src/routeTree.gen.ts +140 -0
- package/src/router.tsx +10 -0
- package/src/routes/__root.tsx +75 -0
- package/src/routes/api/watch.ts +29 -0
- package/src/routes/index.tsx +115 -0
- package/src/routes/milestones.tsx +50 -0
- package/src/routes/search.tsx +84 -0
- package/src/routes/tasks.tsx +63 -0
- package/src/services/progress-database.service.ts +46 -0
- package/src/styles.css +25 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +16 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
# User-Scoped Collections Pattern
|
|
2
|
+
|
|
3
|
+
**Category**: Architecture
|
|
4
|
+
**Applicable To**: Firestore database design in TanStack Start + Cloudflare Workers applications
|
|
5
|
+
**Status**: Stable
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
User-scoped collections store data as subcollections under individual user documents. This pattern provides natural data isolation, simplifies security rules, and makes per-user queries efficient. By embedding the user ID in the document path rather than as a field, you eliminate the need for filtering queries and make security rules trivial to implement.
|
|
12
|
+
|
|
13
|
+
This pattern is essential for multi-tenant applications where each user's data must be completely isolated from other users' data, with the isolation enforced at the database structure level rather than through application logic.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## When to Use This Pattern
|
|
18
|
+
|
|
19
|
+
✅ **Use this pattern when:**
|
|
20
|
+
- Building multi-tenant applications where users have isolated data
|
|
21
|
+
- Working with Firestore in TanStack Start + Cloudflare Workers
|
|
22
|
+
- Need to enforce data isolation at the database level
|
|
23
|
+
- Want simplified security rules (path-based access control)
|
|
24
|
+
- Per-user queries are the primary access pattern
|
|
25
|
+
- Data naturally belongs to a specific user (conversations, credentials, preferences)
|
|
26
|
+
|
|
27
|
+
❌ **Don't use this pattern when:**
|
|
28
|
+
- Data is shared across multiple users (public content, shared documents)
|
|
29
|
+
- Need to query across all users frequently (analytics, admin dashboards)
|
|
30
|
+
- Working with relational databases (use user_id foreign keys instead)
|
|
31
|
+
- Data doesn't have a clear user ownership
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Core Principles
|
|
36
|
+
|
|
37
|
+
1. **Path-Based User Scoping**: User ID is embedded in the document path, not stored as a field in the document
|
|
38
|
+
2. **No user_id Field**: Documents don't need a `user_id` field since the path provides the scope
|
|
39
|
+
3. **Provider-Based Organization**: OAuth credentials and integrations organized by provider (instagram, eventbrite, etc.)
|
|
40
|
+
4. **Nested Subcollections**: Related data (like messages in conversations) stored as subcollections
|
|
41
|
+
5. **Simplified Security Rules**: Firestore security rules can easily enforce user isolation using path variables
|
|
42
|
+
6. **Efficient Queries**: Queries are naturally scoped to a user without requiring filters
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Implementation
|
|
47
|
+
|
|
48
|
+
### Structure
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
{BASE}.users/{userId}/
|
|
52
|
+
├── conversations/{conversationId}
|
|
53
|
+
│ └── messages/{messageId}
|
|
54
|
+
├── credentials/{provider}
|
|
55
|
+
│ └── current
|
|
56
|
+
├── oauth-integrations/{provider}
|
|
57
|
+
│ └── current
|
|
58
|
+
├── preferences/
|
|
59
|
+
│ └── settings
|
|
60
|
+
└── activity/{activityId}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Code Example
|
|
64
|
+
|
|
65
|
+
#### Step 1: Define Collection Helpers
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// src/constant/collections.ts
|
|
69
|
+
export const BASE = getBasePrefix(); // e.g., 'e0.agentbase' or 'agentbase'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get the conversations collection path for a specific user
|
|
73
|
+
*/
|
|
74
|
+
export function getUserConversations(userId: string): string {
|
|
75
|
+
return `${BASE}.users/${userId}/conversations`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the messages collection path for a specific conversation
|
|
80
|
+
*/
|
|
81
|
+
export function getUserConversationMessages(userId: string, conversationId: string): string {
|
|
82
|
+
return `${BASE}.users/${userId}/conversations/${conversationId}/messages`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get the credentials path for a specific user and provider
|
|
87
|
+
* Pattern: users/{userId}/credentials/{provider}
|
|
88
|
+
*/
|
|
89
|
+
export function getUserCredentials(userId: string, provider: string): string {
|
|
90
|
+
return `${BASE}.users/${userId}/credentials/${provider}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get OAuth integration path for a user and provider
|
|
95
|
+
* Pattern: users/{userId}/oauth-integrations/{provider}
|
|
96
|
+
*/
|
|
97
|
+
export function getUserOAuthIntegration(userId: string, provider: string): string {
|
|
98
|
+
return `${BASE}.users/${userId}/oauth-integrations/${provider}`;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
#### Step 2: Create Zod Schemas (No user_id field)
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
// src/schemas/credentials.ts
|
|
106
|
+
import { z } from 'zod';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* User-Scoped Credentials Schema
|
|
110
|
+
*
|
|
111
|
+
* Stored at: users/{userId}/credentials/{provider}/current
|
|
112
|
+
* The userId and provider are implicit in the path.
|
|
113
|
+
*/
|
|
114
|
+
export const InstagramCredentialsSchema = z.object({
|
|
115
|
+
access_token: z.string(),
|
|
116
|
+
instagram_user_id: z.string(),
|
|
117
|
+
instagram_username: z.string().optional(),
|
|
118
|
+
expires_at: z.string().datetime(),
|
|
119
|
+
created_at: z.string().datetime(),
|
|
120
|
+
updated_at: z.string().datetime(),
|
|
121
|
+
// Note: No user_id field - it's in the path!
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export type InstagramCredentials = z.infer<typeof InstagramCredentialsSchema>;
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
// src/schemas/oauth-integration.ts
|
|
129
|
+
import { z } from 'zod';
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* OAuth Integration Schema (User-Scoped)
|
|
133
|
+
*
|
|
134
|
+
* Stored at: users/{userId}/oauth-integrations/{provider}/current
|
|
135
|
+
* Tracks OAuth connections that provide access tokens.
|
|
136
|
+
*/
|
|
137
|
+
export const OAuthIntegrationSchema = z.object({
|
|
138
|
+
connected: z.boolean(),
|
|
139
|
+
connected_at: z.string().datetime(),
|
|
140
|
+
disconnected_at: z.string().datetime().optional(),
|
|
141
|
+
|
|
142
|
+
// OAuth-specific
|
|
143
|
+
requires_refresh: z.boolean(),
|
|
144
|
+
refresh_interval_days: z.number().optional(),
|
|
145
|
+
last_refreshed_at: z.string().datetime().optional(),
|
|
146
|
+
next_refresh_at: z.string().datetime().optional(),
|
|
147
|
+
|
|
148
|
+
// Metadata
|
|
149
|
+
provider_user_id: z.string().optional(),
|
|
150
|
+
provider_username: z.string().optional(),
|
|
151
|
+
scopes: z.array(z.string()).optional(),
|
|
152
|
+
|
|
153
|
+
created_at: z.string().datetime(),
|
|
154
|
+
updated_at: z.string().datetime(),
|
|
155
|
+
// Note: No user_id field - it's in the path!
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
export type OAuthIntegration = z.infer<typeof OAuthIntegrationSchema>;
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
#### Step 3: Create Service Layer
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
// src/services/conversation-database.service.ts
|
|
165
|
+
import { getDocument, setDocument, queryDocuments } from '@prmichaelsen/firebase-admin-sdk-v8'
|
|
166
|
+
import { getUserConversations, getUserConversationMessages } from '@/constant/collections'
|
|
167
|
+
import { ConversationSchema, MessageSchema } from '@/schemas/chat'
|
|
168
|
+
|
|
169
|
+
export class ConversationDatabaseService {
|
|
170
|
+
/**
|
|
171
|
+
* Get messages for a specific conversation
|
|
172
|
+
*/
|
|
173
|
+
static async getMessages(
|
|
174
|
+
userId: string,
|
|
175
|
+
conversationId: string,
|
|
176
|
+
limit = 50,
|
|
177
|
+
startAfter?: string
|
|
178
|
+
): Promise<Message[]> {
|
|
179
|
+
const collectionPath = getUserConversationMessages(userId, conversationId)
|
|
180
|
+
|
|
181
|
+
const results = await queryDocuments(collectionPath, {
|
|
182
|
+
orderBy: [{ field: 'created_at', direction: 'DESCENDING' }],
|
|
183
|
+
limit,
|
|
184
|
+
startAfter: startAfter ? [{ field: 'created_at', value: startAfter }] : undefined,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
return results.map(doc => MessageSchema.parse(doc.data))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Add a message to a conversation
|
|
192
|
+
*/
|
|
193
|
+
static async addMessage(
|
|
194
|
+
userId: string,
|
|
195
|
+
conversationId: string,
|
|
196
|
+
messageData: Omit<Message, 'id' | 'created_at' | 'updated_at'>
|
|
197
|
+
): Promise<Message> {
|
|
198
|
+
const collectionPath = getUserConversationMessages(userId, conversationId)
|
|
199
|
+
const messageId = crypto.randomUUID()
|
|
200
|
+
|
|
201
|
+
const message: Message = {
|
|
202
|
+
...messageData,
|
|
203
|
+
id: messageId,
|
|
204
|
+
created_at: new Date().toISOString(),
|
|
205
|
+
updated_at: new Date().toISOString(),
|
|
206
|
+
// Note: No user_id field needed!
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
await setDocument(collectionPath, messageId, message)
|
|
210
|
+
return message
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Examples
|
|
218
|
+
|
|
219
|
+
### Example 1: OAuth Callback Saving Credentials
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
// src/routes/api/auth/instagram/callback.ts
|
|
223
|
+
import { getServerSession } from '@/lib/auth/session'
|
|
224
|
+
import { setDocument } from '@prmichaelsen/firebase-admin-sdk-v8'
|
|
225
|
+
import { getUserCredentials, getUserOAuthIntegration } from '@/constant/collections'
|
|
226
|
+
|
|
227
|
+
export const APIRoute = createAPIFileRoute('/api/auth/instagram/callback')({
|
|
228
|
+
GET: async ({ request }) => {
|
|
229
|
+
const session = await getServerSession(request)
|
|
230
|
+
if (!session?.user) {
|
|
231
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Exchange code for token...
|
|
235
|
+
const { accessToken, expiresIn, instagramUserId } = await exchangeCodeForToken(code)
|
|
236
|
+
|
|
237
|
+
// Save credentials (user-scoped)
|
|
238
|
+
const credentialsPath = getUserCredentials(session.user.uid, 'instagram')
|
|
239
|
+
await setDocument(credentialsPath, 'current', {
|
|
240
|
+
access_token: accessToken,
|
|
241
|
+
instagram_user_id: instagramUserId.toString(),
|
|
242
|
+
expires_at: new Date(Date.now() + expiresIn * 1000).toISOString(),
|
|
243
|
+
created_at: new Date().toISOString(),
|
|
244
|
+
updated_at: new Date().toISOString(),
|
|
245
|
+
// No user_id field!
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// Save OAuth integration status (user-scoped)
|
|
249
|
+
const integrationPath = getUserOAuthIntegration(session.user.uid, 'instagram')
|
|
250
|
+
await setDocument(integrationPath, 'current', {
|
|
251
|
+
connected: true,
|
|
252
|
+
connected_at: new Date().toISOString(),
|
|
253
|
+
requires_refresh: true,
|
|
254
|
+
refresh_interval_days: 50,
|
|
255
|
+
provider_user_id: instagramUserId.toString(),
|
|
256
|
+
created_at: new Date().toISOString(),
|
|
257
|
+
updated_at: new Date().toISOString(),
|
|
258
|
+
// No user_id field!
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
return Response.redirect('/integrations')
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Example 2: Querying User's Conversations
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
// src/services/conversation-database.service.ts
|
|
270
|
+
export class ConversationDatabaseService {
|
|
271
|
+
static async getUserConversations(userId: string): Promise<Conversation[]> {
|
|
272
|
+
// Query is naturally scoped to user - no filtering needed!
|
|
273
|
+
const collectionPath = getUserConversations(userId)
|
|
274
|
+
|
|
275
|
+
const results = await queryDocuments(collectionPath, {
|
|
276
|
+
orderBy: [{ field: 'updated_at', direction: 'DESCENDING' }],
|
|
277
|
+
limit: 50,
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
return results.map(doc => ConversationSchema.parse(doc.data))
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Example 3: Firestore Security Rules
|
|
286
|
+
|
|
287
|
+
```javascript
|
|
288
|
+
// firestore.rules
|
|
289
|
+
rules_version = '2';
|
|
290
|
+
service cloud.firestore {
|
|
291
|
+
match /databases/{database}/documents {
|
|
292
|
+
// User-scoped conversations
|
|
293
|
+
match /{environment}.users/{userId}/conversations/{conversationId} {
|
|
294
|
+
allow read, write: if request.auth.uid == userId;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// User-scoped messages
|
|
298
|
+
match /{environment}.users/{userId}/conversations/{conversationId}/messages/{messageId} {
|
|
299
|
+
allow read, write: if request.auth.uid == userId;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// User-scoped credentials
|
|
303
|
+
match /{environment}.users/{userId}/credentials/{provider}/{document=**} {
|
|
304
|
+
allow read, write: if request.auth.uid == userId;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// User-scoped OAuth integrations
|
|
308
|
+
match /{environment}.users/{userId}/oauth-integrations/{provider}/{document=**} {
|
|
309
|
+
allow read, write: if request.auth.uid == userId;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## Benefits
|
|
318
|
+
|
|
319
|
+
### 1. Natural Data Isolation
|
|
320
|
+
|
|
321
|
+
All data is naturally scoped to a user with no risk of cross-user data leakage:
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
// All data naturally scoped to user
|
|
325
|
+
const messages = await ConversationDatabaseService.getMessages(userId, conversationId)
|
|
326
|
+
// Impossible to accidentally access another user's data
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### 2. Simplified Security Rules
|
|
330
|
+
|
|
331
|
+
Security rules are trivial - just check if `request.auth.uid == userId`:
|
|
332
|
+
|
|
333
|
+
```javascript
|
|
334
|
+
// Simple, clear security rule
|
|
335
|
+
match /{environment}.users/{userId}/conversations/{conversationId} {
|
|
336
|
+
allow read, write: if request.auth.uid == userId;
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### 3. Efficient Queries
|
|
341
|
+
|
|
342
|
+
Queries are naturally scoped to a user without requiring filters:
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
// No filtering needed - path provides scope
|
|
346
|
+
const conversations = await queryDocuments(
|
|
347
|
+
getUserConversations(userId),
|
|
348
|
+
{ orderBy: [{ field: 'updated_at', direction: 'DESCENDING' }] }
|
|
349
|
+
)
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### 4. Clean Data Model
|
|
353
|
+
|
|
354
|
+
Documents don't need redundant `user_id` fields:
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
// Clean schema - no user_id field
|
|
358
|
+
interface Message {
|
|
359
|
+
id: string
|
|
360
|
+
content: string
|
|
361
|
+
role: 'user' | 'assistant'
|
|
362
|
+
created_at: string
|
|
363
|
+
// No user_id field!
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### 5. Scalable Architecture
|
|
368
|
+
|
|
369
|
+
Firestore can efficiently index and query within user subcollections, making this pattern scale well.
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
## Trade-offs
|
|
374
|
+
|
|
375
|
+
### 1. Cross-User Queries Are Difficult
|
|
376
|
+
|
|
377
|
+
**Downside**: Querying across all users (e.g., for analytics or admin dashboards) requires collection group queries or denormalization.
|
|
378
|
+
|
|
379
|
+
**Mitigation**:
|
|
380
|
+
- Use collection group queries for cross-user analytics
|
|
381
|
+
- Denormalize data into global collections for admin views
|
|
382
|
+
- Use separate analytics database for cross-user reporting
|
|
383
|
+
|
|
384
|
+
### 2. Data Migration Complexity
|
|
385
|
+
|
|
386
|
+
**Downside**: Migrating from global collections to user-scoped requires rewriting paths for all documents.
|
|
387
|
+
|
|
388
|
+
**Mitigation**:
|
|
389
|
+
- Plan data structure carefully upfront
|
|
390
|
+
- Use migration scripts to automate path changes
|
|
391
|
+
- Consider dual-write during migration period
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## Anti-Patterns
|
|
396
|
+
|
|
397
|
+
### ❌ Anti-Pattern 1: Storing user_id in User-Scoped Documents
|
|
398
|
+
|
|
399
|
+
**Description**: Adding a redundant `user_id` field to documents that are already user-scoped by path.
|
|
400
|
+
|
|
401
|
+
**Why it's bad**: Redundant data that can become inconsistent, wastes storage, violates DRY principle.
|
|
402
|
+
|
|
403
|
+
**Instead, do this**: Omit the `user_id` field - the path provides the scope.
|
|
404
|
+
|
|
405
|
+
```typescript
|
|
406
|
+
// ❌ Bad - redundant user_id field
|
|
407
|
+
await setDocument(getUserCredentials(userId, 'instagram'), 'current', {
|
|
408
|
+
user_id: userId, // Redundant! Already in path
|
|
409
|
+
access_token: token,
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// ✅ Good - no user_id field
|
|
413
|
+
await setDocument(getUserCredentials(userId, 'instagram'), 'current', {
|
|
414
|
+
access_token: token,
|
|
415
|
+
// No user_id field needed
|
|
416
|
+
})
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### ❌ Anti-Pattern 2: Using Global Collections for User Data
|
|
420
|
+
|
|
421
|
+
**Description**: Storing user-specific data in global collections with `user_id` filters.
|
|
422
|
+
|
|
423
|
+
**Why it's bad**: Requires filtering every query, complex security rules, risk of data leakage.
|
|
424
|
+
|
|
425
|
+
**Instead, do this**: Use user-scoped subcollections.
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
// ❌ Bad - global collection with filtering
|
|
429
|
+
const credentials = await queryDocuments('credentials', {
|
|
430
|
+
where: [
|
|
431
|
+
{ field: 'user_id', op: 'EQUAL', value: userId },
|
|
432
|
+
{ field: 'provider', op: 'EQUAL', value: 'instagram' }
|
|
433
|
+
]
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
// ✅ Good - user-scoped collection
|
|
437
|
+
const credentialsPath = getUserCredentials(userId, 'instagram')
|
|
438
|
+
const credentials = await getDocument(credentialsPath, 'current')
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### ❌ Anti-Pattern 3: Mixing Scoping Patterns
|
|
442
|
+
|
|
443
|
+
**Description**: Using user-scoped collections for some data and global collections for other user data.
|
|
444
|
+
|
|
445
|
+
**Why it's bad**: Inconsistent patterns make codebase harder to understand and maintain.
|
|
446
|
+
|
|
447
|
+
**Instead, do this**: Be consistent - use user-scoped collections for all user data.
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
// ❌ Bad - inconsistent patterns
|
|
451
|
+
const conversations = getUserConversations(userId) // User-scoped ✓
|
|
452
|
+
const credentials = 'credentials' // Global ✗
|
|
453
|
+
|
|
454
|
+
// ✅ Good - consistent patterns
|
|
455
|
+
const conversations = getUserConversations(userId) // User-scoped ✓
|
|
456
|
+
const credentials = getUserCredentials(userId, provider) // User-scoped ✓
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## Testing Strategy
|
|
462
|
+
|
|
463
|
+
### Unit Tests
|
|
464
|
+
|
|
465
|
+
```typescript
|
|
466
|
+
describe('User-Scoped Collections', () => {
|
|
467
|
+
it('should isolate data by user', async () => {
|
|
468
|
+
const user1Messages = await ConversationDatabaseService.getMessages('user1', 'conv1')
|
|
469
|
+
const user2Messages = await ConversationDatabaseService.getMessages('user2', 'conv1')
|
|
470
|
+
|
|
471
|
+
// Same conversation ID, different users = different data
|
|
472
|
+
expect(user1Messages).not.toEqual(user2Messages)
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('should not require user_id in document', async () => {
|
|
476
|
+
const message = await ConversationDatabaseService.addMessage('user1', 'conv1', {
|
|
477
|
+
content: 'Hello',
|
|
478
|
+
role: 'user',
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
expect(message).not.toHaveProperty('user_id')
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### Integration Tests
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
describe('Firestore Security Rules', () => {
|
|
490
|
+
it('should prevent cross-user access', async () => {
|
|
491
|
+
// User1 tries to access User2's data
|
|
492
|
+
await expect(
|
|
493
|
+
getDocument(getUserConversations('user2'), 'conv1', { auth: user1Auth })
|
|
494
|
+
).rejects.toThrow('Permission denied')
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('should allow user to access own data', async () => {
|
|
498
|
+
const doc = await getDocument(
|
|
499
|
+
getUserConversations('user1'),
|
|
500
|
+
'conv1',
|
|
501
|
+
{ auth: user1Auth }
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
expect(doc).toBeDefined()
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
---
|
|
510
|
+
|
|
511
|
+
## Related Patterns
|
|
512
|
+
|
|
513
|
+
- **[Library Services Pattern](./tanstack-cloudflare.library-services.md)**: Database services use user-scoped collection paths
|
|
514
|
+
- **[SSR Preload Pattern](./tanstack-cloudflare.ssr-preload.md)**: Server-side data fetching with user-scoped collections
|
|
515
|
+
|
|
516
|
+
---
|
|
517
|
+
|
|
518
|
+
## Migration Guide
|
|
519
|
+
|
|
520
|
+
### Step 1: Identify Global Collections
|
|
521
|
+
|
|
522
|
+
Find collections that store user-specific data with `user_id` fields:
|
|
523
|
+
|
|
524
|
+
```typescript
|
|
525
|
+
// Old pattern - global collection
|
|
526
|
+
interface Conversation {
|
|
527
|
+
id: string
|
|
528
|
+
user_id: string // Field that indicates user ownership
|
|
529
|
+
title: string
|
|
530
|
+
created_at: string
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
### Step 2: Create Collection Helper Functions
|
|
535
|
+
|
|
536
|
+
```typescript
|
|
537
|
+
// src/constant/collections.ts
|
|
538
|
+
export function getUserConversations(userId: string): string {
|
|
539
|
+
return `${BASE}.users/${userId}/conversations`;
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
### Step 3: Update Schemas (Remove user_id)
|
|
544
|
+
|
|
545
|
+
```typescript
|
|
546
|
+
// New pattern - user-scoped
|
|
547
|
+
interface Conversation {
|
|
548
|
+
id: string
|
|
549
|
+
// No user_id field!
|
|
550
|
+
title: string
|
|
551
|
+
created_at: string
|
|
552
|
+
}
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### Step 4: Migrate Data
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
// Migration script
|
|
559
|
+
async function migrateConversations() {
|
|
560
|
+
const oldConversations = await queryDocuments('conversations', {})
|
|
561
|
+
|
|
562
|
+
for (const doc of oldConversations) {
|
|
563
|
+
const { id, user_id, ...data } = doc.data
|
|
564
|
+
const newPath = getUserConversations(user_id)
|
|
565
|
+
await setDocument(newPath, id, data)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
console.log(`Migrated ${oldConversations.length} conversations`)
|
|
569
|
+
}
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
### Step 5: Update Service Layer
|
|
573
|
+
|
|
574
|
+
```typescript
|
|
575
|
+
// Update services to use new paths
|
|
576
|
+
export class ConversationDatabaseService {
|
|
577
|
+
static async getUserConversations(userId: string): Promise<Conversation[]> {
|
|
578
|
+
// Old: queryDocuments('conversations', { where: [{ field: 'user_id', ... }] })
|
|
579
|
+
// New: queryDocuments(getUserConversations(userId), {})
|
|
580
|
+
const collectionPath = getUserConversations(userId)
|
|
581
|
+
return await queryDocuments(collectionPath, {})
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
### Step 6: Update Security Rules
|
|
587
|
+
|
|
588
|
+
```javascript
|
|
589
|
+
// Old rules - complex filtering
|
|
590
|
+
match /conversations/{conversationId} {
|
|
591
|
+
allow read, write: if resource.data.user_id == request.auth.uid;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// New rules - simple path-based
|
|
595
|
+
match /{environment}.users/{userId}/conversations/{conversationId} {
|
|
596
|
+
allow read, write: if request.auth.uid == userId;
|
|
597
|
+
}
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
602
|
+
## References
|
|
603
|
+
|
|
604
|
+
- [Firestore Data Model Best Practices](https://firebase.google.com/docs/firestore/data-model)
|
|
605
|
+
- [Firestore Security Rules](https://firebase.google.com/docs/firestore/security/get-started)
|
|
606
|
+
- [firebase-admin-sdk-v8 Documentation](https://github.com/prmichaelsen/firebase-admin-sdk-v8)
|
|
607
|
+
- [Hierarchical Data in Firestore](https://firebase.google.com/docs/firestore/data-model#hierarchical-data)
|
|
608
|
+
|
|
609
|
+
---
|
|
610
|
+
|
|
611
|
+
## Checklist for Implementation
|
|
612
|
+
|
|
613
|
+
- [ ] Collection helper functions created for all user-scoped collections
|
|
614
|
+
- [ ] Zod schemas don't include `user_id` field
|
|
615
|
+
- [ ] Service layer uses collection helpers
|
|
616
|
+
- [ ] Firestore security rules use path-based access control
|
|
617
|
+
- [ ] No direct path strings in service methods
|
|
618
|
+
- [ ] All user data uses user-scoped collections
|
|
619
|
+
- [ ] Migration script created (if migrating from global collections)
|
|
620
|
+
- [ ] Tests verify data isolation between users
|
|
621
|
+
- [ ] Tests verify security rules work correctly
|
|
622
|
+
|
|
623
|
+
---
|
|
624
|
+
|
|
625
|
+
**Status**: Stable - Proven pattern for Firestore in TanStack Start applications
|
|
626
|
+
**Recommendation**: Use for all user-specific data in Firestore
|
|
627
|
+
**Last Updated**: 2026-02-21
|
|
628
|
+
**Contributors**: Patrick Michaelsen
|