@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.
Files changed (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +271 -0
  3. package/dist/auth/SupabaseAuthProvider.d.ts +81 -0
  4. package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -0
  5. package/dist/auth/SupabaseAuthProvider.js +383 -0
  6. package/dist/auth/SupabaseAuthProvider.js.map +1 -0
  7. package/dist/auth/__tests__/conformance.test.d.ts +2 -0
  8. package/dist/auth/__tests__/conformance.test.d.ts.map +1 -0
  9. package/dist/auth/__tests__/conformance.test.js +76 -0
  10. package/dist/auth/__tests__/conformance.test.js.map +1 -0
  11. package/dist/auth/index.d.ts +3 -0
  12. package/dist/auth/index.d.ts.map +1 -0
  13. package/dist/auth/index.js +2 -0
  14. package/dist/auth/index.js.map +1 -0
  15. package/dist/data/SupabaseAuditQuery.d.ts +16 -0
  16. package/dist/data/SupabaseAuditQuery.d.ts.map +1 -0
  17. package/dist/data/SupabaseAuditQuery.js +83 -0
  18. package/dist/data/SupabaseAuditQuery.js.map +1 -0
  19. package/dist/data/SupabaseComputeServerStore.d.ts +26 -0
  20. package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -0
  21. package/dist/data/SupabaseComputeServerStore.js +227 -0
  22. package/dist/data/SupabaseComputeServerStore.js.map +1 -0
  23. package/dist/data/SupabaseDataProvider.d.ts +55 -0
  24. package/dist/data/SupabaseDataProvider.d.ts.map +1 -0
  25. package/dist/data/SupabaseDataProvider.js +114 -0
  26. package/dist/data/SupabaseDataProvider.js.map +1 -0
  27. package/dist/data/SupabaseDefinitionStore.d.ts +39 -0
  28. package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -0
  29. package/dist/data/SupabaseDefinitionStore.js +419 -0
  30. package/dist/data/SupabaseDefinitionStore.js.map +1 -0
  31. package/dist/data/SupabaseEventSink.d.ts +17 -0
  32. package/dist/data/SupabaseEventSink.d.ts.map +1 -0
  33. package/dist/data/SupabaseEventSink.js +31 -0
  34. package/dist/data/SupabaseEventSink.js.map +1 -0
  35. package/dist/data/SupabaseInviteStore.d.ts +22 -0
  36. package/dist/data/SupabaseInviteStore.d.ts.map +1 -0
  37. package/dist/data/SupabaseInviteStore.js +159 -0
  38. package/dist/data/SupabaseInviteStore.js.map +1 -0
  39. package/dist/data/SupabaseOrgStore.d.ts +33 -0
  40. package/dist/data/SupabaseOrgStore.d.ts.map +1 -0
  41. package/dist/data/SupabaseOrgStore.js +455 -0
  42. package/dist/data/SupabaseOrgStore.js.map +1 -0
  43. package/dist/data/SupabaseProjectStore.d.ts +29 -0
  44. package/dist/data/SupabaseProjectStore.d.ts.map +1 -0
  45. package/dist/data/SupabaseProjectStore.js +328 -0
  46. package/dist/data/SupabaseProjectStore.js.map +1 -0
  47. package/dist/data/SupabaseShareLinkStore.d.ts +26 -0
  48. package/dist/data/SupabaseShareLinkStore.d.ts.map +1 -0
  49. package/dist/data/SupabaseShareLinkStore.js +150 -0
  50. package/dist/data/SupabaseShareLinkStore.js.map +1 -0
  51. package/dist/data/__tests__/compute-server-conformance.test.d.ts +2 -0
  52. package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +1 -0
  53. package/dist/data/__tests__/compute-server-conformance.test.js +40 -0
  54. package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -0
  55. package/dist/data/__tests__/definition-conformance.test.d.ts +2 -0
  56. package/dist/data/__tests__/definition-conformance.test.d.ts.map +1 -0
  57. package/dist/data/__tests__/definition-conformance.test.js +85 -0
  58. package/dist/data/__tests__/definition-conformance.test.js.map +1 -0
  59. package/dist/data/__tests__/event-sink-conformance.test.d.ts +2 -0
  60. package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +1 -0
  61. package/dist/data/__tests__/event-sink-conformance.test.js +27 -0
  62. package/dist/data/__tests__/event-sink-conformance.test.js.map +1 -0
  63. package/dist/data/__tests__/invite-conformance.test.d.ts +2 -0
  64. package/dist/data/__tests__/invite-conformance.test.d.ts.map +1 -0
  65. package/dist/data/__tests__/invite-conformance.test.js +45 -0
  66. package/dist/data/__tests__/invite-conformance.test.js.map +1 -0
  67. package/dist/data/__tests__/org-conformance.test.d.ts +2 -0
  68. package/dist/data/__tests__/org-conformance.test.d.ts.map +1 -0
  69. package/dist/data/__tests__/org-conformance.test.js +29 -0
  70. package/dist/data/__tests__/org-conformance.test.js.map +1 -0
  71. package/dist/data/__tests__/project-conformance.test.d.ts +2 -0
  72. package/dist/data/__tests__/project-conformance.test.d.ts.map +1 -0
  73. package/dist/data/__tests__/project-conformance.test.js +57 -0
  74. package/dist/data/__tests__/project-conformance.test.js.map +1 -0
  75. package/dist/data/__tests__/share-link-conformance.test.d.ts +2 -0
  76. package/dist/data/__tests__/share-link-conformance.test.d.ts.map +1 -0
  77. package/dist/data/__tests__/share-link-conformance.test.js +88 -0
  78. package/dist/data/__tests__/share-link-conformance.test.js.map +1 -0
  79. package/dist/data/__tests__/test-helpers.d.ts +53 -0
  80. package/dist/data/__tests__/test-helpers.d.ts.map +1 -0
  81. package/dist/data/__tests__/test-helpers.js +175 -0
  82. package/dist/data/__tests__/test-helpers.js.map +1 -0
  83. package/dist/data/client.d.ts +44 -0
  84. package/dist/data/client.d.ts.map +1 -0
  85. package/dist/data/client.js +40 -0
  86. package/dist/data/client.js.map +1 -0
  87. package/dist/data/index.d.ts +12 -0
  88. package/dist/data/index.d.ts.map +1 -0
  89. package/dist/data/index.js +11 -0
  90. package/dist/data/index.js.map +1 -0
  91. package/dist/data/pagination.d.ts +30 -0
  92. package/dist/data/pagination.d.ts.map +1 -0
  93. package/dist/data/pagination.js +53 -0
  94. package/dist/data/pagination.js.map +1 -0
  95. package/dist/index.d.ts +9 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +6 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/permissions/SupabasePlatformPermissionStore.d.ts +25 -0
  100. package/dist/permissions/SupabasePlatformPermissionStore.d.ts.map +1 -0
  101. package/dist/permissions/SupabasePlatformPermissionStore.js +153 -0
  102. package/dist/permissions/SupabasePlatformPermissionStore.js.map +1 -0
  103. package/dist/permissions/__tests__/conformance.test.d.ts +2 -0
  104. package/dist/permissions/__tests__/conformance.test.d.ts.map +1 -0
  105. package/dist/permissions/__tests__/conformance.test.js +27 -0
  106. package/dist/permissions/__tests__/conformance.test.js.map +1 -0
  107. package/dist/permissions/index.d.ts +2 -0
  108. package/dist/permissions/index.d.ts.map +1 -0
  109. package/dist/permissions/index.js +2 -0
  110. package/dist/permissions/index.js.map +1 -0
  111. package/dist/storage/SupabaseStorageProvider.d.ts +67 -0
  112. package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -0
  113. package/dist/storage/SupabaseStorageProvider.js +136 -0
  114. package/dist/storage/SupabaseStorageProvider.js.map +1 -0
  115. package/dist/storage/__tests__/conformance.test.d.ts +2 -0
  116. package/dist/storage/__tests__/conformance.test.d.ts.map +1 -0
  117. package/dist/storage/__tests__/conformance.test.js +149 -0
  118. package/dist/storage/__tests__/conformance.test.js.map +1 -0
  119. package/dist/storage/index.d.ts +3 -0
  120. package/dist/storage/index.d.ts.map +1 -0
  121. package/dist/storage/index.js +2 -0
  122. package/dist/storage/index.js.map +1 -0
  123. package/dist/userProfile/SupabaseUserProfileProvider.d.ts +16 -0
  124. package/dist/userProfile/SupabaseUserProfileProvider.d.ts.map +1 -0
  125. package/dist/userProfile/SupabaseUserProfileProvider.js +148 -0
  126. package/dist/userProfile/SupabaseUserProfileProvider.js.map +1 -0
  127. package/dist/userProfile/__tests__/conformance.test.d.ts +2 -0
  128. package/dist/userProfile/__tests__/conformance.test.d.ts.map +1 -0
  129. package/dist/userProfile/__tests__/conformance.test.js +38 -0
  130. package/dist/userProfile/__tests__/conformance.test.js.map +1 -0
  131. package/dist/userProfile/index.d.ts +2 -0
  132. package/dist/userProfile/index.d.ts.map +1 -0
  133. package/dist/userProfile/index.js +2 -0
  134. package/dist/userProfile/index.js.map +1 -0
  135. package/package.json +75 -0
  136. package/src/auth/SupabaseAuthProvider.ts +464 -0
  137. package/src/auth/__tests__/conformance.test.ts +78 -0
  138. package/src/auth/index.ts +2 -0
  139. package/src/data/SupabaseAuditQuery.ts +103 -0
  140. package/src/data/SupabaseComputeServerStore.ts +262 -0
  141. package/src/data/SupabaseDataProvider.ts +125 -0
  142. package/src/data/SupabaseDefinitionStore.ts +504 -0
  143. package/src/data/SupabaseEventSink.ts +30 -0
  144. package/src/data/SupabaseInviteStore.ts +183 -0
  145. package/src/data/SupabaseOrgStore.ts +547 -0
  146. package/src/data/SupabaseProjectStore.ts +414 -0
  147. package/src/data/SupabaseShareLinkStore.ts +191 -0
  148. package/src/data/__tests__/compute-server-conformance.test.ts +40 -0
  149. package/src/data/__tests__/definition-conformance.test.ts +89 -0
  150. package/src/data/__tests__/event-sink-conformance.test.ts +32 -0
  151. package/src/data/__tests__/invite-conformance.test.ts +46 -0
  152. package/src/data/__tests__/org-conformance.test.ts +30 -0
  153. package/src/data/__tests__/project-conformance.test.ts +57 -0
  154. package/src/data/__tests__/share-link-conformance.test.ts +87 -0
  155. package/src/data/__tests__/test-helpers.ts +186 -0
  156. package/src/data/client.ts +86 -0
  157. package/src/data/index.ts +11 -0
  158. package/src/data/pagination.ts +68 -0
  159. package/src/index.ts +23 -0
  160. package/src/permissions/SupabasePlatformPermissionStore.ts +161 -0
  161. package/src/permissions/__tests__/conformance.test.ts +28 -0
  162. package/src/permissions/index.ts +1 -0
  163. package/src/storage/SupabaseStorageProvider.ts +177 -0
  164. package/src/storage/__tests__/conformance.test.ts +152 -0
  165. package/src/storage/index.ts +2 -0
  166. package/src/userProfile/SupabaseUserProfileProvider.ts +177 -0
  167. package/src/userProfile/__tests__/conformance.test.ts +39 -0
  168. package/src/userProfile/index.ts +1 -0
  169. package/supabase/.branches/_current_branch +1 -0
  170. package/supabase/.temp/cli-latest +1 -0
  171. package/supabase/config.toml +68 -0
  172. package/supabase/migrations/0001_initial.sql +1084 -0
  173. 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
+ }