@selvajs/supabase-provider 0.14.4-beta.0 → 0.15.0-beta.2

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