@selvajs/supabase-provider 0.11.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/LICENSE +21 -0
- package/README.md +271 -0
- package/dist/auth/SupabaseAuthProvider.d.ts +81 -0
- package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -0
- package/dist/auth/SupabaseAuthProvider.js +383 -0
- package/dist/auth/SupabaseAuthProvider.js.map +1 -0
- package/dist/auth/__tests__/conformance.test.d.ts +2 -0
- package/dist/auth/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/auth/__tests__/conformance.test.js +76 -0
- package/dist/auth/__tests__/conformance.test.js.map +1 -0
- package/dist/auth/index.d.ts +3 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +2 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/data/SupabaseAuditQuery.d.ts +16 -0
- package/dist/data/SupabaseAuditQuery.d.ts.map +1 -0
- package/dist/data/SupabaseAuditQuery.js +83 -0
- package/dist/data/SupabaseAuditQuery.js.map +1 -0
- package/dist/data/SupabaseComputeServerStore.d.ts +26 -0
- package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -0
- package/dist/data/SupabaseComputeServerStore.js +227 -0
- package/dist/data/SupabaseComputeServerStore.js.map +1 -0
- package/dist/data/SupabaseDataProvider.d.ts +55 -0
- package/dist/data/SupabaseDataProvider.d.ts.map +1 -0
- package/dist/data/SupabaseDataProvider.js +114 -0
- package/dist/data/SupabaseDataProvider.js.map +1 -0
- package/dist/data/SupabaseDefinitionStore.d.ts +39 -0
- package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -0
- package/dist/data/SupabaseDefinitionStore.js +419 -0
- package/dist/data/SupabaseDefinitionStore.js.map +1 -0
- package/dist/data/SupabaseEventSink.d.ts +17 -0
- package/dist/data/SupabaseEventSink.d.ts.map +1 -0
- package/dist/data/SupabaseEventSink.js +31 -0
- package/dist/data/SupabaseEventSink.js.map +1 -0
- package/dist/data/SupabaseInviteStore.d.ts +22 -0
- package/dist/data/SupabaseInviteStore.d.ts.map +1 -0
- package/dist/data/SupabaseInviteStore.js +159 -0
- package/dist/data/SupabaseInviteStore.js.map +1 -0
- package/dist/data/SupabaseOrgStore.d.ts +33 -0
- package/dist/data/SupabaseOrgStore.d.ts.map +1 -0
- package/dist/data/SupabaseOrgStore.js +455 -0
- package/dist/data/SupabaseOrgStore.js.map +1 -0
- package/dist/data/SupabaseProjectStore.d.ts +29 -0
- package/dist/data/SupabaseProjectStore.d.ts.map +1 -0
- package/dist/data/SupabaseProjectStore.js +328 -0
- package/dist/data/SupabaseProjectStore.js.map +1 -0
- package/dist/data/SupabaseShareLinkStore.d.ts +26 -0
- package/dist/data/SupabaseShareLinkStore.d.ts.map +1 -0
- package/dist/data/SupabaseShareLinkStore.js +150 -0
- package/dist/data/SupabaseShareLinkStore.js.map +1 -0
- package/dist/data/__tests__/compute-server-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/compute-server-conformance.test.js +40 -0
- package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -0
- package/dist/data/__tests__/definition-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/definition-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/definition-conformance.test.js +85 -0
- package/dist/data/__tests__/definition-conformance.test.js.map +1 -0
- package/dist/data/__tests__/event-sink-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/event-sink-conformance.test.js +27 -0
- package/dist/data/__tests__/event-sink-conformance.test.js.map +1 -0
- package/dist/data/__tests__/invite-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/invite-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/invite-conformance.test.js +45 -0
- package/dist/data/__tests__/invite-conformance.test.js.map +1 -0
- package/dist/data/__tests__/org-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/org-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/org-conformance.test.js +29 -0
- package/dist/data/__tests__/org-conformance.test.js.map +1 -0
- package/dist/data/__tests__/project-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/project-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/project-conformance.test.js +57 -0
- package/dist/data/__tests__/project-conformance.test.js.map +1 -0
- package/dist/data/__tests__/share-link-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/share-link-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/share-link-conformance.test.js +88 -0
- package/dist/data/__tests__/share-link-conformance.test.js.map +1 -0
- package/dist/data/__tests__/test-helpers.d.ts +53 -0
- package/dist/data/__tests__/test-helpers.d.ts.map +1 -0
- package/dist/data/__tests__/test-helpers.js +175 -0
- package/dist/data/__tests__/test-helpers.js.map +1 -0
- package/dist/data/client.d.ts +44 -0
- package/dist/data/client.d.ts.map +1 -0
- package/dist/data/client.js +40 -0
- package/dist/data/client.js.map +1 -0
- package/dist/data/index.d.ts +12 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +11 -0
- package/dist/data/index.js.map +1 -0
- package/dist/data/pagination.d.ts +30 -0
- package/dist/data/pagination.d.ts.map +1 -0
- package/dist/data/pagination.js +53 -0
- package/dist/data/pagination.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/permissions/SupabasePlatformPermissionStore.d.ts +25 -0
- package/dist/permissions/SupabasePlatformPermissionStore.d.ts.map +1 -0
- package/dist/permissions/SupabasePlatformPermissionStore.js +153 -0
- package/dist/permissions/SupabasePlatformPermissionStore.js.map +1 -0
- package/dist/permissions/__tests__/conformance.test.d.ts +2 -0
- package/dist/permissions/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/permissions/__tests__/conformance.test.js +27 -0
- package/dist/permissions/__tests__/conformance.test.js.map +1 -0
- package/dist/permissions/index.d.ts +2 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +2 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/storage/SupabaseStorageProvider.d.ts +67 -0
- package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -0
- package/dist/storage/SupabaseStorageProvider.js +136 -0
- package/dist/storage/SupabaseStorageProvider.js.map +1 -0
- package/dist/storage/__tests__/conformance.test.d.ts +2 -0
- package/dist/storage/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/storage/__tests__/conformance.test.js +149 -0
- package/dist/storage/__tests__/conformance.test.js.map +1 -0
- package/dist/storage/index.d.ts +3 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/userProfile/SupabaseUserProfileProvider.d.ts +16 -0
- package/dist/userProfile/SupabaseUserProfileProvider.d.ts.map +1 -0
- package/dist/userProfile/SupabaseUserProfileProvider.js +148 -0
- package/dist/userProfile/SupabaseUserProfileProvider.js.map +1 -0
- package/dist/userProfile/__tests__/conformance.test.d.ts +2 -0
- package/dist/userProfile/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/userProfile/__tests__/conformance.test.js +38 -0
- package/dist/userProfile/__tests__/conformance.test.js.map +1 -0
- package/dist/userProfile/index.d.ts +2 -0
- package/dist/userProfile/index.d.ts.map +1 -0
- package/dist/userProfile/index.js +2 -0
- package/dist/userProfile/index.js.map +1 -0
- package/package.json +75 -0
- package/src/auth/SupabaseAuthProvider.ts +464 -0
- package/src/auth/__tests__/conformance.test.ts +78 -0
- package/src/auth/index.ts +2 -0
- package/src/data/SupabaseAuditQuery.ts +103 -0
- package/src/data/SupabaseComputeServerStore.ts +262 -0
- package/src/data/SupabaseDataProvider.ts +125 -0
- package/src/data/SupabaseDefinitionStore.ts +504 -0
- package/src/data/SupabaseEventSink.ts +30 -0
- package/src/data/SupabaseInviteStore.ts +183 -0
- package/src/data/SupabaseOrgStore.ts +547 -0
- package/src/data/SupabaseProjectStore.ts +414 -0
- package/src/data/SupabaseShareLinkStore.ts +191 -0
- package/src/data/__tests__/compute-server-conformance.test.ts +40 -0
- package/src/data/__tests__/definition-conformance.test.ts +89 -0
- package/src/data/__tests__/event-sink-conformance.test.ts +32 -0
- package/src/data/__tests__/invite-conformance.test.ts +46 -0
- package/src/data/__tests__/org-conformance.test.ts +30 -0
- package/src/data/__tests__/project-conformance.test.ts +57 -0
- package/src/data/__tests__/share-link-conformance.test.ts +87 -0
- package/src/data/__tests__/test-helpers.ts +186 -0
- package/src/data/client.ts +86 -0
- package/src/data/index.ts +11 -0
- package/src/data/pagination.ts +68 -0
- package/src/index.ts +23 -0
- package/src/permissions/SupabasePlatformPermissionStore.ts +161 -0
- package/src/permissions/__tests__/conformance.test.ts +28 -0
- package/src/permissions/index.ts +1 -0
- package/src/storage/SupabaseStorageProvider.ts +177 -0
- package/src/storage/__tests__/conformance.test.ts +152 -0
- package/src/storage/index.ts +2 -0
- package/src/userProfile/SupabaseUserProfileProvider.ts +177 -0
- package/src/userProfile/__tests__/conformance.test.ts +39 -0
- package/src/userProfile/index.ts +1 -0
- package/supabase/.branches/_current_branch +1 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/config.toml +68 -0
- package/supabase/migrations/0001_initial.sql +1084 -0
- package/supabase/seed.sql +13 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IProjectStore,
|
|
3
|
+
IEventSink,
|
|
4
|
+
Project,
|
|
5
|
+
ProjectMember,
|
|
6
|
+
ProjectRole,
|
|
7
|
+
RequestContext,
|
|
8
|
+
ListOptions,
|
|
9
|
+
Page
|
|
10
|
+
} from '@selvajs/platform';
|
|
11
|
+
import { ProviderError, auditSoftDelete, actorFrom, NoopEventSink } from '@selvajs/platform';
|
|
12
|
+
import type { ClientBundle } from './client.js';
|
|
13
|
+
import { nextCursorFromRange, orderColumn, toRange } from './pagination.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Project + project-membership store backed by Postgres. Visibility semantics
|
|
17
|
+
* (public / org / private) are enforced by the `visible_project()` helper in
|
|
18
|
+
* RLS. Access predicates (canEdit / canManage / canView etc.) live as pure
|
|
19
|
+
* functions in `@selvajs/platform/access`; the route layer composes them with
|
|
20
|
+
* pre-loaded entities. See `packages/selva/src/lib/server/access.server.ts`.
|
|
21
|
+
*
|
|
22
|
+
* `createProject` atomically seeds the creator as the project `owner` in
|
|
23
|
+
* `project_members` so subsequent user-scoped reads can see it.
|
|
24
|
+
*/
|
|
25
|
+
export class SupabaseProjectStore implements IProjectStore {
|
|
26
|
+
private readonly events: IEventSink;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly clients: ClientBundle,
|
|
30
|
+
events: IEventSink = new NoopEventSink()
|
|
31
|
+
) {
|
|
32
|
+
this.events = events;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async listProjects(
|
|
36
|
+
ctx: RequestContext,
|
|
37
|
+
orgId: string,
|
|
38
|
+
opts?: ListOptions
|
|
39
|
+
): Promise<Page<Project>> {
|
|
40
|
+
const range = toRange(opts);
|
|
41
|
+
const direction = opts?.orderDir ?? 'desc';
|
|
42
|
+
const { data, error, count } = await this.clients
|
|
43
|
+
.forRequest(ctx)
|
|
44
|
+
.from('projects')
|
|
45
|
+
.select('*', { count: 'exact' })
|
|
46
|
+
.eq('org_id', orgId)
|
|
47
|
+
.is('deleted_at', null)
|
|
48
|
+
.order(orderColumn(opts?.orderBy), { ascending: direction === 'asc' })
|
|
49
|
+
.range(range.from, range.to);
|
|
50
|
+
if (error) throw mapError(error);
|
|
51
|
+
const items = (data ?? []).map(rowToProject);
|
|
52
|
+
return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async getProject(ctx: RequestContext, id: string): Promise<Project | null> {
|
|
56
|
+
const { data, error } = await this.clients
|
|
57
|
+
.forRequest(ctx)
|
|
58
|
+
.from('projects')
|
|
59
|
+
.select('*')
|
|
60
|
+
.eq('id', id)
|
|
61
|
+
.is('deleted_at', null)
|
|
62
|
+
.maybeSingle();
|
|
63
|
+
if (error) throw mapError(error);
|
|
64
|
+
return data ? rowToProject(data) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getProjectBySlug(
|
|
68
|
+
ctx: RequestContext,
|
|
69
|
+
orgId: string,
|
|
70
|
+
slug: string
|
|
71
|
+
): Promise<Project | null> {
|
|
72
|
+
const { data, error } = await this.clients
|
|
73
|
+
.forRequest(ctx)
|
|
74
|
+
.from('projects')
|
|
75
|
+
.select('*')
|
|
76
|
+
.eq('org_id', orgId)
|
|
77
|
+
.eq('slug', slug)
|
|
78
|
+
.is('deleted_at', null)
|
|
79
|
+
.maybeSingle();
|
|
80
|
+
if (error) throw mapError(error);
|
|
81
|
+
return data ? rowToProject(data) : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async createProject(ctx: RequestContext, project: Project): Promise<void> {
|
|
85
|
+
const client = this.clients.forRequest(ctx);
|
|
86
|
+
const { error } = await client.from('projects').insert(projectToRow(project));
|
|
87
|
+
if (error) throw mapError(error);
|
|
88
|
+
|
|
89
|
+
// Seed owner membership so user-scoped reads see the project post-create.
|
|
90
|
+
// Upsert so a stale soft-deleted row from a prior project with the same id
|
|
91
|
+
// is reactivated rather than blocking creation.
|
|
92
|
+
const { error: memberError } = await client.from('project_members').upsert(
|
|
93
|
+
{
|
|
94
|
+
project_id: project.id,
|
|
95
|
+
user_id: project.ownerId,
|
|
96
|
+
role: 'owner',
|
|
97
|
+
joined_at: new Date().toISOString(),
|
|
98
|
+
deleted_at: null
|
|
99
|
+
},
|
|
100
|
+
{ onConflict: 'project_id,user_id' }
|
|
101
|
+
);
|
|
102
|
+
if (memberError) throw mapError(memberError);
|
|
103
|
+
await this.events.emit({
|
|
104
|
+
type: 'project.created',
|
|
105
|
+
projectId: project.id,
|
|
106
|
+
orgId: project.orgId,
|
|
107
|
+
actorId: actorFrom(ctx)
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async updateProject(
|
|
112
|
+
ctx: RequestContext,
|
|
113
|
+
id: string,
|
|
114
|
+
patch: Partial<
|
|
115
|
+
Pick<Project, 'name' | 'slug' | 'description' | 'visibility' | 'autoJoinOnUpload'>
|
|
116
|
+
>
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
const row: Record<string, unknown> = {};
|
|
119
|
+
if (patch.name !== undefined) row.name = patch.name;
|
|
120
|
+
if (patch.slug !== undefined) row.slug = patch.slug;
|
|
121
|
+
if (patch.description !== undefined) row.description = patch.description;
|
|
122
|
+
if (patch.visibility !== undefined) row.visibility = patch.visibility;
|
|
123
|
+
if (patch.autoJoinOnUpload !== undefined) row.auto_join_on_upload = patch.autoJoinOnUpload;
|
|
124
|
+
if (Object.keys(row).length === 0) return;
|
|
125
|
+
if (ctx.userId) row.updated_by = ctx.userId;
|
|
126
|
+
|
|
127
|
+
const { data, error } = await this.clients
|
|
128
|
+
.forRequest(ctx)
|
|
129
|
+
.from('projects')
|
|
130
|
+
.update(row)
|
|
131
|
+
.eq('id', id)
|
|
132
|
+
.is('deleted_at', null)
|
|
133
|
+
.select('id');
|
|
134
|
+
if (error) throw mapError(error);
|
|
135
|
+
if (!data || data.length === 0) throw new ProviderError(`Project '${id}' not found`, 404);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async deleteProject(ctx: RequestContext, id: string): Promise<void> {
|
|
139
|
+
// §9 soft-delete with cascade. Mirrors LocalProjectStore: project →
|
|
140
|
+
// project_members, definitions. FK CASCADE doesn't fire on soft-delete,
|
|
141
|
+
// so the cascade is in app code.
|
|
142
|
+
const client = this.clients.forRequest(ctx);
|
|
143
|
+
const stamp = auditSoftDelete(ctx, ctx.userId);
|
|
144
|
+
const stampRow = stampToRow(stamp);
|
|
145
|
+
|
|
146
|
+
const { data, error } = await client
|
|
147
|
+
.from('projects')
|
|
148
|
+
.update(stampRow)
|
|
149
|
+
.eq('id', id)
|
|
150
|
+
.is('deleted_at', null)
|
|
151
|
+
.select('id');
|
|
152
|
+
if (error) throw mapError(error);
|
|
153
|
+
if (!data || data.length === 0) throw new ProviderError(`Project '${id}' not found`, 404);
|
|
154
|
+
|
|
155
|
+
const { error: pmErr } = await client
|
|
156
|
+
.from('project_members')
|
|
157
|
+
.update(stampRow)
|
|
158
|
+
.eq('project_id', id)
|
|
159
|
+
.is('deleted_at', null);
|
|
160
|
+
if (pmErr) throw mapError(pmErr);
|
|
161
|
+
|
|
162
|
+
const { error: defErr } = await client
|
|
163
|
+
.from('definitions')
|
|
164
|
+
.update(stampRow)
|
|
165
|
+
.eq('project_id', id)
|
|
166
|
+
.is('deleted_at', null);
|
|
167
|
+
if (defErr) throw mapError(defErr);
|
|
168
|
+
|
|
169
|
+
await this.events.emit({ type: 'project.deleted', projectId: id, actorId: actorFrom(ctx) });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============================================================================
|
|
173
|
+
// Project members
|
|
174
|
+
// ============================================================================
|
|
175
|
+
async listProjectMembers(
|
|
176
|
+
ctx: RequestContext,
|
|
177
|
+
projectId: string,
|
|
178
|
+
opts?: ListOptions
|
|
179
|
+
): Promise<Page<ProjectMember>> {
|
|
180
|
+
const range = toRange(opts);
|
|
181
|
+
const { data, error, count } = await this.clients
|
|
182
|
+
.forRequest(ctx)
|
|
183
|
+
.from('project_members')
|
|
184
|
+
.select('*', { count: 'exact' })
|
|
185
|
+
.eq('project_id', projectId)
|
|
186
|
+
.is('deleted_at', null)
|
|
187
|
+
.order('joined_at', { ascending: (opts?.orderDir ?? 'desc') === 'asc' })
|
|
188
|
+
.range(range.from, range.to);
|
|
189
|
+
if (error) throw mapError(error);
|
|
190
|
+
const items = (data ?? []).map(rowToProjectMember);
|
|
191
|
+
return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async getProjectMember(
|
|
195
|
+
ctx: RequestContext,
|
|
196
|
+
projectId: string,
|
|
197
|
+
userId: string
|
|
198
|
+
): Promise<ProjectMember | null> {
|
|
199
|
+
const { data, error } = await this.clients
|
|
200
|
+
.forRequest(ctx)
|
|
201
|
+
.from('project_members')
|
|
202
|
+
.select('*')
|
|
203
|
+
.eq('project_id', projectId)
|
|
204
|
+
.eq('user_id', userId)
|
|
205
|
+
.is('deleted_at', null)
|
|
206
|
+
.maybeSingle();
|
|
207
|
+
if (error) throw mapError(error);
|
|
208
|
+
return data ? rowToProjectMember(data) : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async addProjectMember(ctx: RequestContext, member: ProjectMember): Promise<void> {
|
|
212
|
+
// Upsert reactivates a prior soft-deleted row instead of throwing
|
|
213
|
+
// duplicate-key. Mirrors LocalProjectStore.addProjectMember.
|
|
214
|
+
const row: Record<string, unknown> = {
|
|
215
|
+
...projectMemberToRow(member),
|
|
216
|
+
deleted_at: null
|
|
217
|
+
};
|
|
218
|
+
if (ctx.userId) row.updated_by = ctx.userId;
|
|
219
|
+
const { error } = await this.clients
|
|
220
|
+
.forRequest(ctx)
|
|
221
|
+
.from('project_members')
|
|
222
|
+
.upsert(row, { onConflict: 'project_id,user_id' });
|
|
223
|
+
if (error) throw mapError(error);
|
|
224
|
+
await this.events.emit({
|
|
225
|
+
type: 'project_member.added',
|
|
226
|
+
projectId: member.projectId,
|
|
227
|
+
userId: member.userId,
|
|
228
|
+
actorId: actorFrom(ctx)
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async updateProjectMemberRole(
|
|
233
|
+
ctx: RequestContext,
|
|
234
|
+
projectId: string,
|
|
235
|
+
userId: string,
|
|
236
|
+
role: ProjectRole
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
const row: Record<string, unknown> = { role };
|
|
239
|
+
if (ctx.userId) row.updated_by = ctx.userId;
|
|
240
|
+
const { data, error } = await this.clients
|
|
241
|
+
.forRequest(ctx)
|
|
242
|
+
.from('project_members')
|
|
243
|
+
.update(row)
|
|
244
|
+
.eq('project_id', projectId)
|
|
245
|
+
.eq('user_id', userId)
|
|
246
|
+
.is('deleted_at', null)
|
|
247
|
+
.select('user_id');
|
|
248
|
+
if (error) throw mapError(error);
|
|
249
|
+
if (!data || data.length === 0)
|
|
250
|
+
throw new ProviderError(`Project member '${userId}' not found`, 404);
|
|
251
|
+
await this.events.emit({
|
|
252
|
+
type: 'project_member.role_changed',
|
|
253
|
+
projectId,
|
|
254
|
+
userId,
|
|
255
|
+
role,
|
|
256
|
+
actorId: actorFrom(ctx)
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async removeProjectMember(ctx: RequestContext, projectId: string, userId: string): Promise<void> {
|
|
261
|
+
const stamp = auditSoftDelete(ctx, ctx.userId);
|
|
262
|
+
const { error } = await this.clients
|
|
263
|
+
.forRequest(ctx)
|
|
264
|
+
.from('project_members')
|
|
265
|
+
.update(stampToRow(stamp))
|
|
266
|
+
.eq('project_id', projectId)
|
|
267
|
+
.eq('user_id', userId)
|
|
268
|
+
.is('deleted_at', null);
|
|
269
|
+
if (error) throw mapError(error);
|
|
270
|
+
await this.events.emit({
|
|
271
|
+
type: 'project_member.removed',
|
|
272
|
+
projectId,
|
|
273
|
+
userId,
|
|
274
|
+
actorId: actorFrom(ctx)
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ============================================================================
|
|
280
|
+
// Row ↔ domain mappers
|
|
281
|
+
// ============================================================================
|
|
282
|
+
//
|
|
283
|
+
// Audit columns (`created_by` / `updated_by`) FK to `auth.users(id)` with
|
|
284
|
+
// `ON DELETE SET NULL`; they can legitimately become NULL when the user is
|
|
285
|
+
// deleted (spec §8). Mappers fall back to `owner_id` so the domain type
|
|
286
|
+
// stays non-nullable. Also true for ProjectMember audit columns below.
|
|
287
|
+
// that haven't applied the latest migration — the mapper falls back to
|
|
288
|
+
// owner_id / user_id / joined_at in those cases.
|
|
289
|
+
|
|
290
|
+
interface ProjectRow {
|
|
291
|
+
id: string;
|
|
292
|
+
org_id: string;
|
|
293
|
+
name: string;
|
|
294
|
+
slug: string;
|
|
295
|
+
description: string | null;
|
|
296
|
+
visibility: Project['visibility'];
|
|
297
|
+
owner_id: string;
|
|
298
|
+
created_by?: string | null;
|
|
299
|
+
updated_by?: string | null;
|
|
300
|
+
auto_join_on_upload?: boolean | null;
|
|
301
|
+
created_at: string;
|
|
302
|
+
updated_at: string;
|
|
303
|
+
deleted_at?: string | null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
interface ProjectMemberRow {
|
|
307
|
+
project_id: string;
|
|
308
|
+
user_id: string;
|
|
309
|
+
role: ProjectRole;
|
|
310
|
+
joined_at: string;
|
|
311
|
+
updated_at?: string | null;
|
|
312
|
+
updated_by?: string | null;
|
|
313
|
+
deleted_at?: string | null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function rowToProject(row: ProjectRow): Project {
|
|
317
|
+
return {
|
|
318
|
+
id: row.id,
|
|
319
|
+
orgId: row.org_id,
|
|
320
|
+
name: row.name,
|
|
321
|
+
slug: row.slug,
|
|
322
|
+
description: row.description ?? undefined,
|
|
323
|
+
visibility: row.visibility,
|
|
324
|
+
ownerId: row.owner_id,
|
|
325
|
+
createdBy: row.created_by ?? row.owner_id,
|
|
326
|
+
updatedBy: row.updated_by ?? row.owner_id,
|
|
327
|
+
autoJoinOnUpload: row.auto_join_on_upload ?? false,
|
|
328
|
+
createdAt: row.created_at,
|
|
329
|
+
updatedAt: row.updated_at,
|
|
330
|
+
deletedAt: row.deleted_at ?? null
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function projectToRow(p: Project): ProjectRow {
|
|
335
|
+
return {
|
|
336
|
+
id: p.id,
|
|
337
|
+
org_id: p.orgId,
|
|
338
|
+
name: p.name,
|
|
339
|
+
slug: p.slug,
|
|
340
|
+
description: p.description ?? null,
|
|
341
|
+
visibility: p.visibility,
|
|
342
|
+
owner_id: p.ownerId,
|
|
343
|
+
created_by: p.createdBy,
|
|
344
|
+
updated_by: p.updatedBy,
|
|
345
|
+
auto_join_on_upload: p.autoJoinOnUpload,
|
|
346
|
+
created_at: p.createdAt,
|
|
347
|
+
updated_at: p.updatedAt,
|
|
348
|
+
deleted_at: p.deletedAt ?? null
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function rowToProjectMember(row: ProjectMemberRow): ProjectMember {
|
|
353
|
+
return {
|
|
354
|
+
projectId: row.project_id,
|
|
355
|
+
userId: row.user_id,
|
|
356
|
+
role: row.role,
|
|
357
|
+
joinedAt: row.joined_at,
|
|
358
|
+
updatedAt: row.updated_at ?? row.joined_at,
|
|
359
|
+
updatedBy: row.updated_by ?? row.user_id,
|
|
360
|
+
deletedAt: row.deleted_at ?? null
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function projectMemberToRow(m: ProjectMember): ProjectMemberRow {
|
|
365
|
+
return {
|
|
366
|
+
project_id: m.projectId,
|
|
367
|
+
user_id: m.userId,
|
|
368
|
+
role: m.role,
|
|
369
|
+
joined_at: m.joinedAt
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ============================================================================
|
|
374
|
+
// Error translation
|
|
375
|
+
// ============================================================================
|
|
376
|
+
interface PostgrestError {
|
|
377
|
+
code?: string;
|
|
378
|
+
message?: string;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Build a row payload from an audit-soft-delete stamp. `updated_by` only goes
|
|
383
|
+
* into the row when ctx.userId is set — passing `''` would violate the
|
|
384
|
+
* `references auth.users(id)` FK on system contexts. The DB trigger sets
|
|
385
|
+
* `updated_at`, but for soft-delete we set it explicitly so it matches
|
|
386
|
+
* `deleted_at` (single timestamp for the deletion event).
|
|
387
|
+
*/
|
|
388
|
+
function stampToRow(stamp: {
|
|
389
|
+
updatedAt: string;
|
|
390
|
+
updatedBy: string;
|
|
391
|
+
deletedAt: string;
|
|
392
|
+
}): Record<string, unknown> {
|
|
393
|
+
const row: Record<string, unknown> = {
|
|
394
|
+
deleted_at: stamp.deletedAt,
|
|
395
|
+
updated_at: stamp.updatedAt
|
|
396
|
+
};
|
|
397
|
+
if (stamp.updatedBy) row.updated_by = stamp.updatedBy;
|
|
398
|
+
return row;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function mapError(e: unknown): Error {
|
|
402
|
+
const pg = e as PostgrestError;
|
|
403
|
+
if (pg?.code === '23505') return new ProviderError(pg.message ?? 'Duplicate record', 409);
|
|
404
|
+
if (pg?.code === '23503') return new ProviderError(pg.message ?? 'Foreign key violation', 409);
|
|
405
|
+
if (e instanceof Error) return e;
|
|
406
|
+
if (e && typeof e === 'object') {
|
|
407
|
+
const obj = e as { message?: string; details?: string; hint?: string; code?: string };
|
|
408
|
+
const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
|
|
409
|
+
const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
|
|
410
|
+
Object.assign(err, obj);
|
|
411
|
+
return err;
|
|
412
|
+
}
|
|
413
|
+
return new Error(String(e));
|
|
414
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IShareLinkStore,
|
|
3
|
+
IEventSink,
|
|
4
|
+
ShareLink,
|
|
5
|
+
RequestContext,
|
|
6
|
+
ListOptions,
|
|
7
|
+
Page
|
|
8
|
+
} from '@selvajs/platform';
|
|
9
|
+
import { ProviderError, actorFrom, NoopEventSink } from '@selvajs/platform';
|
|
10
|
+
import type { ClientBundle } from './client.js';
|
|
11
|
+
import { nextCursorFromRange, toRange } from './pagination.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Spec §7 — share-link store backed by Postgres.
|
|
15
|
+
*
|
|
16
|
+
* Token resolution (`getByTokenHash`) and atomic increment use the
|
|
17
|
+
* SECURITY DEFINER `try_increment_share_link_solve_count` RPC + service-
|
|
18
|
+
* role client to bypass RLS — the token IS the credential, not the
|
|
19
|
+
* authenticated user identity.
|
|
20
|
+
*
|
|
21
|
+
* `tryIncrementSolveCount` returns a single number on success or null when
|
|
22
|
+
* the cap is hit. The RPC enforces "increment iff under cap and live" in
|
|
23
|
+
* one statement, so even concurrent solves can't overshoot the cap.
|
|
24
|
+
*/
|
|
25
|
+
export class SupabaseShareLinkStore implements IShareLinkStore {
|
|
26
|
+
private readonly events: IEventSink;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly clients: ClientBundle,
|
|
30
|
+
events: IEventSink = new NoopEventSink()
|
|
31
|
+
) {
|
|
32
|
+
this.events = events;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async create(ctx: RequestContext, link: ShareLink): Promise<void> {
|
|
36
|
+
const { error } = await this.clients
|
|
37
|
+
.forRequest(ctx)
|
|
38
|
+
.from('share_links')
|
|
39
|
+
.insert(linkToRow(link));
|
|
40
|
+
if (error) throw mapError(error);
|
|
41
|
+
await this.events.emit({
|
|
42
|
+
type: 'share_link.minted',
|
|
43
|
+
linkId: link.id,
|
|
44
|
+
definitionId: link.definitionId,
|
|
45
|
+
actorId: actorFrom(ctx)
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async listByDefinition(
|
|
50
|
+
ctx: RequestContext,
|
|
51
|
+
definitionId: string,
|
|
52
|
+
opts?: ListOptions
|
|
53
|
+
): Promise<Page<ShareLink>> {
|
|
54
|
+
const range = toRange(opts);
|
|
55
|
+
const { data, error, count } = await this.clients
|
|
56
|
+
.forRequest(ctx)
|
|
57
|
+
.from('share_links')
|
|
58
|
+
.select('*', { count: 'exact' })
|
|
59
|
+
.eq('definition_guid', definitionId)
|
|
60
|
+
.is('revoked_at', null)
|
|
61
|
+
.order('created_at', { ascending: false })
|
|
62
|
+
.range(range.from, range.to);
|
|
63
|
+
if (error) throw mapError(error);
|
|
64
|
+
const items = (data ?? []).map(rowToLink);
|
|
65
|
+
return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async getById(ctx: RequestContext, id: string): Promise<ShareLink | null> {
|
|
69
|
+
const { data, error } = await this.clients
|
|
70
|
+
.forRequest(ctx)
|
|
71
|
+
.from('share_links')
|
|
72
|
+
.select('*')
|
|
73
|
+
.eq('id', id)
|
|
74
|
+
.maybeSingle();
|
|
75
|
+
if (error) throw mapError(error);
|
|
76
|
+
return data ? rowToLink(data) : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async getByTokenHash(_ctx: RequestContext, tokenHash: string): Promise<ShareLink | null> {
|
|
80
|
+
// Service-role: token resolution must work for anonymous requests too.
|
|
81
|
+
// RLS would scope the query down to "links the current user can see"
|
|
82
|
+
// — exactly the wrong semantic for token-credentialed access.
|
|
83
|
+
const { data, error } = await this.clients.serviceClient
|
|
84
|
+
.from('share_links')
|
|
85
|
+
.select('*, definitions!inner(deleted_at)')
|
|
86
|
+
.eq('token_hash', tokenHash)
|
|
87
|
+
.is('revoked_at', null)
|
|
88
|
+
.is('definitions.deleted_at', null)
|
|
89
|
+
.maybeSingle();
|
|
90
|
+
if (error) throw mapError(error);
|
|
91
|
+
return data ? rowToLink(data) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async revoke(ctx: RequestContext, id: string): Promise<void> {
|
|
95
|
+
// Idempotent: succeeds whether or not the row exists / is already
|
|
96
|
+
// revoked. The `is null` predicate on revoked_at means a re-revoke
|
|
97
|
+
// is a no-op (no row updated).
|
|
98
|
+
const { error } = await this.clients
|
|
99
|
+
.forRequest(ctx)
|
|
100
|
+
.from('share_links')
|
|
101
|
+
.update({ revoked_at: new Date().toISOString() })
|
|
102
|
+
.eq('id', id)
|
|
103
|
+
.is('revoked_at', null);
|
|
104
|
+
if (error) throw mapError(error);
|
|
105
|
+
await this.events.emit({ type: 'share_link.revoked', linkId: id, actorId: actorFrom(ctx) });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async tryIncrementSolveCount(_ctx: RequestContext, id: string): Promise<number | null> {
|
|
109
|
+
// SECURITY DEFINER RPC — bypasses RLS. Atomic check-and-increment.
|
|
110
|
+
// Returns NULL when the link is missing/revoked/expired/capped; the
|
|
111
|
+
// row update is the only way to get a numeric return.
|
|
112
|
+
const { data, error } = await this.clients.serviceClient.rpc(
|
|
113
|
+
'try_increment_share_link_solve_count',
|
|
114
|
+
{ link_id: id }
|
|
115
|
+
);
|
|
116
|
+
if (error) throw mapError(error);
|
|
117
|
+
return typeof data === 'number' ? data : null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ============================================================================
|
|
122
|
+
// Row ↔ domain mappers
|
|
123
|
+
// ============================================================================
|
|
124
|
+
interface ShareLinkRow {
|
|
125
|
+
id: string;
|
|
126
|
+
definition_guid: string;
|
|
127
|
+
channel: 'live' | 'draft';
|
|
128
|
+
token_hash: string;
|
|
129
|
+
name: string | null;
|
|
130
|
+
created_by: string;
|
|
131
|
+
created_at: string;
|
|
132
|
+
expires_at: string | null;
|
|
133
|
+
revoked_at: string | null;
|
|
134
|
+
allow_solve: boolean;
|
|
135
|
+
max_solves: number | null;
|
|
136
|
+
solve_count: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function rowToLink(row: ShareLinkRow): ShareLink {
|
|
140
|
+
return {
|
|
141
|
+
id: row.id,
|
|
142
|
+
definitionId: row.definition_guid,
|
|
143
|
+
channel: row.channel,
|
|
144
|
+
tokenHash: row.token_hash,
|
|
145
|
+
name: row.name ?? undefined,
|
|
146
|
+
createdBy: row.created_by,
|
|
147
|
+
createdAt: row.created_at,
|
|
148
|
+
expiresAt: row.expires_at,
|
|
149
|
+
revokedAt: row.revoked_at,
|
|
150
|
+
allowSolve: row.allow_solve,
|
|
151
|
+
maxSolves: row.max_solves,
|
|
152
|
+
solveCount: row.solve_count
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function linkToRow(l: ShareLink): Record<string, unknown> {
|
|
157
|
+
return {
|
|
158
|
+
id: l.id,
|
|
159
|
+
definition_guid: l.definitionId,
|
|
160
|
+
channel: l.channel,
|
|
161
|
+
token_hash: l.tokenHash,
|
|
162
|
+
name: l.name ?? null,
|
|
163
|
+
created_by: l.createdBy,
|
|
164
|
+
created_at: l.createdAt,
|
|
165
|
+
expires_at: l.expiresAt ?? null,
|
|
166
|
+
revoked_at: l.revokedAt ?? null,
|
|
167
|
+
allow_solve: l.allowSolve,
|
|
168
|
+
max_solves: l.maxSolves ?? null,
|
|
169
|
+
solve_count: l.solveCount
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface PostgrestError {
|
|
174
|
+
code?: string;
|
|
175
|
+
message?: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function mapError(e: unknown): Error {
|
|
179
|
+
const pg = e as PostgrestError;
|
|
180
|
+
if (pg?.code === '23505') return new ProviderError(pg.message ?? 'Duplicate record', 409);
|
|
181
|
+
if (pg?.code === '23503') return new ProviderError(pg.message ?? 'Foreign key violation', 409);
|
|
182
|
+
if (e instanceof Error) return e;
|
|
183
|
+
if (e && typeof e === 'object') {
|
|
184
|
+
const obj = e as { message?: string; details?: string; hint?: string; code?: string };
|
|
185
|
+
const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
|
|
186
|
+
const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
|
|
187
|
+
Object.assign(err, obj);
|
|
188
|
+
return err;
|
|
189
|
+
}
|
|
190
|
+
return new Error(String(e));
|
|
191
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, beforeEach, it } from 'vitest';
|
|
2
|
+
import { runComputeServerStoreConformance } from '@selvajs/platform/testing';
|
|
3
|
+
import { SupabaseComputeServerStore } from '../SupabaseComputeServerStore.js';
|
|
4
|
+
import { readEnv, resetAllData, seedUser } from './test-helpers.js';
|
|
5
|
+
|
|
6
|
+
const envCtx = readEnv();
|
|
7
|
+
|
|
8
|
+
if (!envCtx) {
|
|
9
|
+
describe.skip('SupabaseComputeServerStore (skipped: no live stack)', () => {
|
|
10
|
+
it('populate packages/providers/supabase/.env.test with Supabase creds to run these tests', () => {});
|
|
11
|
+
});
|
|
12
|
+
} else {
|
|
13
|
+
describe('SupabaseComputeServerStore', () => {
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
await resetAllData(envCtx);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
runComputeServerStoreConformance({
|
|
19
|
+
name: 'SupabaseComputeServerStore',
|
|
20
|
+
createStore: () => new SupabaseComputeServerStore(envCtx.bundle),
|
|
21
|
+
seedOrg: async (orgId) => {
|
|
22
|
+
// `compute_servers.owner_org_id` and `compute_server_org_defaults.org_id`
|
|
23
|
+
// FK to `orgs(id)`; the suite invents an `orgId` per org-scoped test,
|
|
24
|
+
// so we materialize the parent org here. Owner is a throwaway user
|
|
25
|
+
// (the org-scoped tests use SYSTEM_CONTEXT for writes anyway).
|
|
26
|
+
const { userId } = await seedUser(envCtx, '');
|
|
27
|
+
const now = new Date().toISOString();
|
|
28
|
+
const { error } = await envCtx.adminClient.from('orgs').insert({
|
|
29
|
+
id: orgId,
|
|
30
|
+
name: 'Compute Test Org',
|
|
31
|
+
slug: `compute-${orgId.slice(0, 8)}`,
|
|
32
|
+
owner_id: userId,
|
|
33
|
+
created_at: now,
|
|
34
|
+
updated_at: now
|
|
35
|
+
});
|
|
36
|
+
if (error) throw error;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|