@selvajs/supabase-provider 0.14.3 → 0.14.4-beta.1

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 (160) hide show
  1. package/LICENSE +1 -1
  2. package/dist/auth/SupabaseAuthProvider.d.ts +57 -0
  3. package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -1
  4. package/dist/auth/SupabaseAuthProvider.js +123 -18
  5. package/dist/auth/SupabaseAuthProvider.js.map +1 -1
  6. package/dist/data/SupabaseComputeServerStore.d.ts +34 -2
  7. package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -1
  8. package/dist/data/SupabaseComputeServerStore.js +146 -39
  9. package/dist/data/SupabaseComputeServerStore.js.map +1 -1
  10. package/dist/data/SupabaseDataProvider.d.ts +18 -3
  11. package/dist/data/SupabaseDataProvider.d.ts.map +1 -1
  12. package/dist/data/SupabaseDataProvider.js +83 -18
  13. package/dist/data/SupabaseDataProvider.js.map +1 -1
  14. package/dist/data/SupabaseDefinitionStore.d.ts +3 -1
  15. package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -1
  16. package/dist/data/SupabaseDefinitionStore.js +94 -67
  17. package/dist/data/SupabaseDefinitionStore.js.map +1 -1
  18. package/dist/data/SupabaseInviteStore.d.ts.map +1 -1
  19. package/dist/data/SupabaseInviteStore.js +11 -22
  20. package/dist/data/SupabaseInviteStore.js.map +1 -1
  21. package/dist/data/SupabaseOrgStore.d.ts.map +1 -1
  22. package/dist/data/SupabaseOrgStore.js +49 -86
  23. package/dist/data/SupabaseOrgStore.js.map +1 -1
  24. package/dist/data/SupabaseProjectStore.d.ts.map +1 -1
  25. package/dist/data/SupabaseProjectStore.js +51 -70
  26. package/dist/data/SupabaseProjectStore.js.map +1 -1
  27. package/dist/data/SupabaseShareLinkStore.d.ts.map +1 -1
  28. package/dist/data/SupabaseShareLinkStore.js +13 -27
  29. package/dist/data/SupabaseShareLinkStore.js.map +1 -1
  30. package/dist/data/client.d.ts +35 -3
  31. package/dist/data/client.d.ts.map +1 -1
  32. package/dist/data/client.js +67 -16
  33. package/dist/data/client.js.map +1 -1
  34. package/dist/data/errors.d.ts +2 -0
  35. package/dist/data/errors.d.ts.map +1 -0
  36. package/dist/data/errors.js +23 -0
  37. package/dist/data/errors.js.map +1 -0
  38. package/dist/data/index.d.ts +8 -2
  39. package/dist/data/index.d.ts.map +1 -1
  40. package/dist/data/index.js +6 -1
  41. package/dist/data/index.js.map +1 -1
  42. package/dist/data/migrationHead.d.ts +14 -0
  43. package/dist/data/migrationHead.d.ts.map +1 -0
  44. package/dist/data/migrationHead.js +14 -0
  45. package/dist/data/migrationHead.js.map +1 -0
  46. package/dist/data/query.d.ts +20 -0
  47. package/dist/data/query.d.ts.map +1 -0
  48. package/dist/data/query.js +13 -0
  49. package/dist/data/query.js.map +1 -0
  50. package/dist/data/rowStamp.d.ts +38 -0
  51. package/dist/data/rowStamp.d.ts.map +1 -0
  52. package/dist/data/rowStamp.js +43 -0
  53. package/dist/data/rowStamp.js.map +1 -0
  54. package/dist/index.d.ts +2 -2
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/permissions/SupabasePlatformPermissionStore.d.ts +5 -3
  59. package/dist/permissions/SupabasePlatformPermissionStore.d.ts.map +1 -1
  60. package/dist/permissions/SupabasePlatformPermissionStore.js +17 -30
  61. package/dist/permissions/SupabasePlatformPermissionStore.js.map +1 -1
  62. package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -1
  63. package/dist/storage/SupabaseStorageProvider.js +3 -0
  64. package/dist/storage/SupabaseStorageProvider.js.map +1 -1
  65. package/package.json +13 -14
  66. package/supabase/.branches/_current_branch +1 -0
  67. package/supabase/.temp/cli-latest +1 -0
  68. package/supabase/migrations/20260711120000_selva_definition_schema_version.sql +18 -0
  69. package/supabase/migrations/20260712120000_user_profiles_disabled_and_gin.sql +95 -0
  70. package/supabase/migrations/20260712123000_selva_migration_head_fn.sql +38 -0
  71. package/supabase/migrations/20260713120000_selva_next_version_number.sql +59 -0
  72. package/supabase/migrations/20260713140000_selva_solve_cache_limit.sql +15 -0
  73. package/dist/auth/__tests__/conformance.test.d.ts +0 -2
  74. package/dist/auth/__tests__/conformance.test.d.ts.map +0 -1
  75. package/dist/auth/__tests__/conformance.test.js +0 -76
  76. package/dist/auth/__tests__/conformance.test.js.map +0 -1
  77. package/dist/data/__tests__/compute-server-conformance.test.d.ts +0 -2
  78. package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +0 -1
  79. package/dist/data/__tests__/compute-server-conformance.test.js +0 -40
  80. package/dist/data/__tests__/compute-server-conformance.test.js.map +0 -1
  81. package/dist/data/__tests__/definition-conformance.test.d.ts +0 -2
  82. package/dist/data/__tests__/definition-conformance.test.d.ts.map +0 -1
  83. package/dist/data/__tests__/definition-conformance.test.js +0 -85
  84. package/dist/data/__tests__/definition-conformance.test.js.map +0 -1
  85. package/dist/data/__tests__/event-sink-conformance.test.d.ts +0 -2
  86. package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +0 -1
  87. package/dist/data/__tests__/event-sink-conformance.test.js +0 -27
  88. package/dist/data/__tests__/event-sink-conformance.test.js.map +0 -1
  89. package/dist/data/__tests__/invite-conformance.test.d.ts +0 -2
  90. package/dist/data/__tests__/invite-conformance.test.d.ts.map +0 -1
  91. package/dist/data/__tests__/invite-conformance.test.js +0 -45
  92. package/dist/data/__tests__/invite-conformance.test.js.map +0 -1
  93. package/dist/data/__tests__/org-conformance.test.d.ts +0 -2
  94. package/dist/data/__tests__/org-conformance.test.d.ts.map +0 -1
  95. package/dist/data/__tests__/org-conformance.test.js +0 -29
  96. package/dist/data/__tests__/org-conformance.test.js.map +0 -1
  97. package/dist/data/__tests__/project-conformance.test.d.ts +0 -2
  98. package/dist/data/__tests__/project-conformance.test.d.ts.map +0 -1
  99. package/dist/data/__tests__/project-conformance.test.js +0 -57
  100. package/dist/data/__tests__/project-conformance.test.js.map +0 -1
  101. package/dist/data/__tests__/share-link-conformance.test.d.ts +0 -2
  102. package/dist/data/__tests__/share-link-conformance.test.d.ts.map +0 -1
  103. package/dist/data/__tests__/share-link-conformance.test.js +0 -88
  104. package/dist/data/__tests__/share-link-conformance.test.js.map +0 -1
  105. package/dist/data/__tests__/solve-metric-sink-conformance.test.d.ts +0 -2
  106. package/dist/data/__tests__/solve-metric-sink-conformance.test.d.ts.map +0 -1
  107. package/dist/data/__tests__/solve-metric-sink-conformance.test.js +0 -52
  108. package/dist/data/__tests__/solve-metric-sink-conformance.test.js.map +0 -1
  109. package/dist/data/__tests__/test-helpers.d.ts +0 -52
  110. package/dist/data/__tests__/test-helpers.d.ts.map +0 -1
  111. package/dist/data/__tests__/test-helpers.js +0 -177
  112. package/dist/data/__tests__/test-helpers.js.map +0 -1
  113. package/dist/permissions/__tests__/conformance.test.d.ts +0 -2
  114. package/dist/permissions/__tests__/conformance.test.d.ts.map +0 -1
  115. package/dist/permissions/__tests__/conformance.test.js +0 -27
  116. package/dist/permissions/__tests__/conformance.test.js.map +0 -1
  117. package/dist/storage/__tests__/conformance.test.d.ts +0 -2
  118. package/dist/storage/__tests__/conformance.test.d.ts.map +0 -1
  119. package/dist/storage/__tests__/conformance.test.js +0 -149
  120. package/dist/storage/__tests__/conformance.test.js.map +0 -1
  121. package/dist/userProfile/__tests__/conformance.test.d.ts +0 -2
  122. package/dist/userProfile/__tests__/conformance.test.d.ts.map +0 -1
  123. package/dist/userProfile/__tests__/conformance.test.js +0 -38
  124. package/dist/userProfile/__tests__/conformance.test.js.map +0 -1
  125. package/src/auth/SupabaseAuthProvider.ts +0 -464
  126. package/src/auth/__tests__/conformance.test.ts +0 -78
  127. package/src/auth/index.ts +0 -2
  128. package/src/cli/sync-migrations.ts +0 -133
  129. package/src/data/SupabaseAuditQuery.ts +0 -103
  130. package/src/data/SupabaseComputeServerStore.ts +0 -262
  131. package/src/data/SupabaseDataProvider.ts +0 -133
  132. package/src/data/SupabaseDefinitionStore.ts +0 -520
  133. package/src/data/SupabaseEventSink.ts +0 -30
  134. package/src/data/SupabaseInviteStore.ts +0 -183
  135. package/src/data/SupabaseOrgStore.ts +0 -552
  136. package/src/data/SupabaseProjectStore.ts +0 -454
  137. package/src/data/SupabaseShareLinkStore.ts +0 -191
  138. package/src/data/SupabaseSolveMetricSink.ts +0 -38
  139. package/src/data/__tests__/compute-server-conformance.test.ts +0 -40
  140. package/src/data/__tests__/definition-conformance.test.ts +0 -89
  141. package/src/data/__tests__/event-sink-conformance.test.ts +0 -32
  142. package/src/data/__tests__/invite-conformance.test.ts +0 -46
  143. package/src/data/__tests__/org-conformance.test.ts +0 -30
  144. package/src/data/__tests__/project-conformance.test.ts +0 -57
  145. package/src/data/__tests__/share-link-conformance.test.ts +0 -87
  146. package/src/data/__tests__/solve-metric-sink-conformance.test.ts +0 -59
  147. package/src/data/__tests__/test-helpers.ts +0 -188
  148. package/src/data/client.ts +0 -98
  149. package/src/data/index.ts +0 -12
  150. package/src/data/pagination.ts +0 -68
  151. package/src/index.ts +0 -24
  152. package/src/permissions/SupabasePlatformPermissionStore.ts +0 -161
  153. package/src/permissions/__tests__/conformance.test.ts +0 -28
  154. package/src/permissions/index.ts +0 -1
  155. package/src/storage/SupabaseStorageProvider.ts +0 -182
  156. package/src/storage/__tests__/conformance.test.ts +0 -152
  157. package/src/storage/index.ts +0 -2
  158. package/src/userProfile/SupabaseUserProfileProvider.ts +0 -177
  159. package/src/userProfile/__tests__/conformance.test.ts +0 -39
  160. package/src/userProfile/index.ts +0 -1
@@ -1,454 +0,0 @@
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
- async reactivateProject(
173
- ctx: RequestContext,
174
- orgId: string,
175
- slug: string
176
- ): Promise<Project | null> {
177
- const client = this.clients.forRequest(ctx);
178
-
179
- // Clear the tombstone. The WHERE deleted_at IS NOT NULL ensures we never
180
- // accidentally clobber a live row, and the unique constraint means there
181
- // can be at most one tombstone per (org_id, slug) at any time.
182
- const { data, error } = await client
183
- .from('projects')
184
- .update({ deleted_at: null })
185
- .eq('org_id', orgId)
186
- .eq('slug', slug)
187
- .not('deleted_at', 'is', null)
188
- .select('*')
189
- .maybeSingle();
190
- if (error) throw mapError(error);
191
- if (!data) return null;
192
-
193
- // Reactivate the owner's project_members row (deleteProject cascades to it).
194
- const { error: pmErr } = await client
195
- .from('project_members')
196
- .update({ deleted_at: null })
197
- .eq('project_id', data.id)
198
- .eq('user_id', data.owner_id)
199
- .not('deleted_at', 'is', null);
200
- if (pmErr) throw mapError(pmErr);
201
-
202
- const project = rowToProject(data);
203
- await this.events.emit({
204
- type: 'project.created',
205
- projectId: project.id,
206
- orgId: project.orgId,
207
- actorId: actorFrom(ctx)
208
- });
209
- return project;
210
- }
211
-
212
- // ============================================================================
213
- // Project members
214
- // ============================================================================
215
- async listProjectMembers(
216
- ctx: RequestContext,
217
- projectId: string,
218
- opts?: ListOptions
219
- ): Promise<Page<ProjectMember>> {
220
- const range = toRange(opts);
221
- const { data, error, count } = await this.clients
222
- .forRequest(ctx)
223
- .from('project_members')
224
- .select('*', { count: 'exact' })
225
- .eq('project_id', projectId)
226
- .is('deleted_at', null)
227
- .order('joined_at', { ascending: (opts?.orderDir ?? 'desc') === 'asc' })
228
- .range(range.from, range.to);
229
- if (error) throw mapError(error);
230
- const items = (data ?? []).map(rowToProjectMember);
231
- return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
232
- }
233
-
234
- async getProjectMember(
235
- ctx: RequestContext,
236
- projectId: string,
237
- userId: string
238
- ): Promise<ProjectMember | null> {
239
- const { data, error } = await this.clients
240
- .forRequest(ctx)
241
- .from('project_members')
242
- .select('*')
243
- .eq('project_id', projectId)
244
- .eq('user_id', userId)
245
- .is('deleted_at', null)
246
- .maybeSingle();
247
- if (error) throw mapError(error);
248
- return data ? rowToProjectMember(data) : null;
249
- }
250
-
251
- async addProjectMember(ctx: RequestContext, member: ProjectMember): Promise<void> {
252
- // Upsert reactivates a prior soft-deleted row instead of throwing
253
- // duplicate-key. Mirrors LocalProjectStore.addProjectMember.
254
- const row: Record<string, unknown> = {
255
- ...projectMemberToRow(member),
256
- deleted_at: null
257
- };
258
- if (ctx.userId) row.updated_by = ctx.userId;
259
- const { error } = await this.clients
260
- .forRequest(ctx)
261
- .from('project_members')
262
- .upsert(row, { onConflict: 'project_id,user_id' });
263
- if (error) throw mapError(error);
264
- await this.events.emit({
265
- type: 'project_member.added',
266
- projectId: member.projectId,
267
- userId: member.userId,
268
- actorId: actorFrom(ctx)
269
- });
270
- }
271
-
272
- async updateProjectMemberRole(
273
- ctx: RequestContext,
274
- projectId: string,
275
- userId: string,
276
- role: ProjectRole
277
- ): Promise<void> {
278
- const row: Record<string, unknown> = { role };
279
- if (ctx.userId) row.updated_by = ctx.userId;
280
- const { data, error } = await this.clients
281
- .forRequest(ctx)
282
- .from('project_members')
283
- .update(row)
284
- .eq('project_id', projectId)
285
- .eq('user_id', userId)
286
- .is('deleted_at', null)
287
- .select('user_id');
288
- if (error) throw mapError(error);
289
- if (!data || data.length === 0)
290
- throw new ProviderError(`Project member '${userId}' not found`, 404);
291
- await this.events.emit({
292
- type: 'project_member.role_changed',
293
- projectId,
294
- userId,
295
- role,
296
- actorId: actorFrom(ctx)
297
- });
298
- }
299
-
300
- async removeProjectMember(ctx: RequestContext, projectId: string, userId: string): Promise<void> {
301
- const stamp = auditSoftDelete(ctx, ctx.userId);
302
- const { error } = await this.clients
303
- .forRequest(ctx)
304
- .from('project_members')
305
- .update(stampToRow(stamp))
306
- .eq('project_id', projectId)
307
- .eq('user_id', userId)
308
- .is('deleted_at', null);
309
- if (error) throw mapError(error);
310
- await this.events.emit({
311
- type: 'project_member.removed',
312
- projectId,
313
- userId,
314
- actorId: actorFrom(ctx)
315
- });
316
- }
317
- }
318
-
319
- // ============================================================================
320
- // Row ↔ domain mappers
321
- // ============================================================================
322
- //
323
- // Audit columns (`created_by` / `updated_by`) FK to `auth.users(id)` with
324
- // `ON DELETE SET NULL`; they can legitimately become NULL when the user is
325
- // deleted (spec §8). Mappers fall back to `owner_id` so the domain type
326
- // stays non-nullable. Also true for ProjectMember audit columns below.
327
- // that haven't applied the latest migration — the mapper falls back to
328
- // owner_id / user_id / joined_at in those cases.
329
-
330
- interface ProjectRow {
331
- id: string;
332
- org_id: string;
333
- name: string;
334
- slug: string;
335
- description: string | null;
336
- visibility: Project['visibility'];
337
- owner_id: string;
338
- created_by?: string | null;
339
- updated_by?: string | null;
340
- auto_join_on_upload?: boolean | null;
341
- created_at: string;
342
- updated_at: string;
343
- deleted_at?: string | null;
344
- }
345
-
346
- interface ProjectMemberRow {
347
- project_id: string;
348
- user_id: string;
349
- role: ProjectRole;
350
- joined_at: string;
351
- updated_at?: string | null;
352
- updated_by?: string | null;
353
- deleted_at?: string | null;
354
- }
355
-
356
- function rowToProject(row: ProjectRow): Project {
357
- return {
358
- id: row.id,
359
- orgId: row.org_id,
360
- name: row.name,
361
- slug: row.slug,
362
- description: row.description ?? undefined,
363
- visibility: row.visibility,
364
- ownerId: row.owner_id,
365
- createdBy: row.created_by ?? row.owner_id,
366
- updatedBy: row.updated_by ?? row.owner_id,
367
- autoJoinOnUpload: row.auto_join_on_upload ?? false,
368
- createdAt: row.created_at,
369
- updatedAt: row.updated_at,
370
- deletedAt: row.deleted_at ?? null
371
- };
372
- }
373
-
374
- function projectToRow(p: Project): ProjectRow {
375
- return {
376
- id: p.id,
377
- org_id: p.orgId,
378
- name: p.name,
379
- slug: p.slug,
380
- description: p.description ?? null,
381
- visibility: p.visibility,
382
- owner_id: p.ownerId,
383
- created_by: p.createdBy,
384
- updated_by: p.updatedBy,
385
- auto_join_on_upload: p.autoJoinOnUpload,
386
- created_at: p.createdAt,
387
- updated_at: p.updatedAt,
388
- deleted_at: p.deletedAt ?? null
389
- };
390
- }
391
-
392
- function rowToProjectMember(row: ProjectMemberRow): ProjectMember {
393
- return {
394
- projectId: row.project_id,
395
- userId: row.user_id,
396
- role: row.role,
397
- joinedAt: row.joined_at,
398
- updatedAt: row.updated_at ?? row.joined_at,
399
- updatedBy: row.updated_by ?? row.user_id,
400
- deletedAt: row.deleted_at ?? null
401
- };
402
- }
403
-
404
- function projectMemberToRow(m: ProjectMember): ProjectMemberRow {
405
- return {
406
- project_id: m.projectId,
407
- user_id: m.userId,
408
- role: m.role,
409
- joined_at: m.joinedAt
410
- };
411
- }
412
-
413
- // ============================================================================
414
- // Error translation
415
- // ============================================================================
416
- interface PostgrestError {
417
- code?: string;
418
- message?: string;
419
- }
420
-
421
- /**
422
- * Build a row payload from an audit-soft-delete stamp. `updated_by` only goes
423
- * into the row when ctx.userId is set — passing `''` would violate the
424
- * `references auth.users(id)` FK on system contexts. The DB trigger sets
425
- * `updated_at`, but for soft-delete we set it explicitly so it matches
426
- * `deleted_at` (single timestamp for the deletion event).
427
- */
428
- function stampToRow(stamp: {
429
- updatedAt: string;
430
- updatedBy: string;
431
- deletedAt: string;
432
- }): Record<string, unknown> {
433
- const row: Record<string, unknown> = {
434
- deleted_at: stamp.deletedAt,
435
- updated_at: stamp.updatedAt
436
- };
437
- if (stamp.updatedBy) row.updated_by = stamp.updatedBy;
438
- return row;
439
- }
440
-
441
- function mapError(e: unknown): Error {
442
- const pg = e as PostgrestError;
443
- if (pg?.code === '23505') return new ProviderError(pg.message ?? 'Duplicate record', 409);
444
- if (pg?.code === '23503') return new ProviderError(pg.message ?? 'Foreign key violation', 409);
445
- if (e instanceof Error) return e;
446
- if (e && typeof e === 'object') {
447
- const obj = e as { message?: string; details?: string; hint?: string; code?: string };
448
- const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
449
- const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
450
- Object.assign(err, obj);
451
- return err;
452
- }
453
- return new Error(String(e));
454
- }
@@ -1,191 +0,0 @@
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
- }
@@ -1,38 +0,0 @@
1
- import type { ISolveMetricSink, RequestContext, SolveMetric } from '@selvajs/platform';
2
- import type { ClientBundle } from './client.js';
3
-
4
- /**
5
- * Persists every solve's timing to `selva.solve_metrics`. Writes use the
6
- * service-role client so RLS does not gate the telemetry — the sink runs as a
7
- * system process attached to the compute route, not the requesting user.
8
- *
9
- * Per the `ISolveMetricSink` contract, `record` MUST NOT throw. It sits on the
10
- * hot path of every solve and the response has already been produced by the
11
- * time we're here, so a failed insert is logged and swallowed.
12
- */
13
- export class SupabaseSolveMetricSink implements ISolveMetricSink {
14
- constructor(private readonly clients: ClientBundle) {}
15
-
16
- async record(_ctx: RequestContext, metric: SolveMetric): Promise<void> {
17
- try {
18
- const { error } = await this.clients.serviceClient.from('solve_metrics').insert({
19
- actor_id: _ctx.userId || 'system',
20
- definition_url: metric.definitionUrl,
21
- definition_id: metric.definitionId,
22
- version_id: metric.versionId,
23
- channel: metric.channel,
24
- org_id: metric.orgId,
25
- duration_ms: metric.durationMs,
26
- ok: metric.ok,
27
- failure_kind: metric.failureKind,
28
- error_count: metric.errorCount,
29
- warning_count: metric.warningCount
30
- });
31
- if (error) {
32
- console.error('[SupabaseSolveMetricSink] insert failed:', error.message, { metric });
33
- }
34
- } catch (err) {
35
- console.error('[SupabaseSolveMetricSink] unexpected error:', err, { metric });
36
- }
37
- }
38
- }