@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,504 @@
1
+ import type {
2
+ IDefinitionStore,
3
+ IEventSink,
4
+ DefinitionRecord,
5
+ DefinitionRecordPatch,
6
+ DefinitionStatus,
7
+ DefinitionFileExt,
8
+ DefinitionVersion,
9
+ RequestContext,
10
+ DefinitionListOptions,
11
+ ListOptions,
12
+ Page
13
+ } from '@selvajs/platform';
14
+ import { ProviderError, auditSoftDelete, actorFrom, NoopEventSink } from '@selvajs/platform';
15
+ import type { ClientBundle } from './client.js';
16
+ import { nextCursorFromRange, toRange } from './pagination.js';
17
+
18
+ /**
19
+ * Definition metadata + version store backed by Postgres. Spec §6 versioning:
20
+ * `definitions` carries the parent record and the `live`/`draft` channel
21
+ * pointers; `definition_versions` carries the immutable per-upload rows.
22
+ *
23
+ * Deletion protection (§6) is enforced by FK constraints — `live_version_id`
24
+ * and `draft_version_id` are ON DELETE RESTRICT. Trying to delete a
25
+ * referenced version raises 23503 which `mapError` turns into a 409.
26
+ *
27
+ * `incrementSolveCount` uses a SQL function for atomic UPDATE — no
28
+ * read-modify-write race like the local provider has.
29
+ */
30
+ export class SupabaseDefinitionStore implements IDefinitionStore {
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
+ // ============================================================================
41
+ // Definitions
42
+ // ============================================================================
43
+
44
+ async list(ctx: RequestContext, opts?: DefinitionListOptions): Promise<Page<DefinitionRecord>> {
45
+ return this.runList(ctx, undefined, opts);
46
+ }
47
+
48
+ async listByProject(
49
+ ctx: RequestContext,
50
+ projectId: string,
51
+ opts?: DefinitionListOptions
52
+ ): Promise<Page<DefinitionRecord>> {
53
+ return this.runList(ctx, { projectId }, opts);
54
+ }
55
+
56
+ async listPublic(
57
+ ctx: RequestContext,
58
+ opts?: DefinitionListOptions & { orgId?: string }
59
+ ): Promise<Page<DefinitionRecord>> {
60
+ return this.runList(ctx, { publicOnly: true, orgId: opts?.orgId }, opts);
61
+ }
62
+
63
+ private async runList(
64
+ ctx: RequestContext,
65
+ filter: { projectId?: string; publicOnly?: boolean; orgId?: string } | undefined,
66
+ opts?: DefinitionListOptions
67
+ ): Promise<Page<DefinitionRecord>> {
68
+ const range = toRange(opts);
69
+ const direction = opts?.orderDir ?? 'desc';
70
+
71
+ let query = this.clients
72
+ .forRequest(ctx)
73
+ .from('definitions')
74
+ .select('*', { count: 'exact' })
75
+ .is('deleted_at', null);
76
+
77
+ if (filter?.projectId) query = query.eq('project_id', filter.projectId);
78
+
79
+ if (filter?.publicOnly) {
80
+ query = this.clients
81
+ .forRequest(ctx)
82
+ .from('definitions')
83
+ .select('*, project:projects!inner(visibility, org_id)', {
84
+ count: 'exact'
85
+ })
86
+ .is('deleted_at', null)
87
+ .eq('project.visibility', 'public');
88
+ if (filter.orgId) query = query.eq('project.org_id', filter.orgId);
89
+ if (filter.projectId) query = query.eq('project_id', filter.projectId);
90
+ }
91
+
92
+ if (opts?.statuses?.length) {
93
+ query = query.in('status', opts.statuses);
94
+ } else {
95
+ const excluded: string[] = [];
96
+ if (!opts?.includePending) excluded.push('pending');
97
+ if (!opts?.includeArchived) excluded.push('archived');
98
+ if (excluded.length) {
99
+ query = query.not('status', 'in', `(${excluded.join(',')})`);
100
+ }
101
+ }
102
+
103
+ query = query
104
+ .order(definitionOrderColumn(opts?.orderBy), { ascending: direction === 'asc' })
105
+ .range(range.from, range.to);
106
+
107
+ const { data, error, count } = await query;
108
+ if (error) throw mapError(error);
109
+ const items = (data ?? []).map(rowToRecord);
110
+ return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
111
+ }
112
+
113
+ async get(ctx: RequestContext, guid: string): Promise<DefinitionRecord | null> {
114
+ const { data, error } = await this.clients
115
+ .forRequest(ctx)
116
+ .from('definitions')
117
+ .select('*')
118
+ .eq('guid', guid)
119
+ .is('deleted_at', null)
120
+ .maybeSingle();
121
+ if (error) throw mapError(error);
122
+ return data ? rowToRecord(data) : null;
123
+ }
124
+
125
+ async create(ctx: RequestContext, record: DefinitionRecord): Promise<void> {
126
+ const { error } = await this.clients
127
+ .forRequest(ctx)
128
+ .from('definitions')
129
+ .insert(recordToRow(record));
130
+ if (error) throw mapError(error);
131
+ await this.events.emit({
132
+ type: 'definition.created',
133
+ definitionId: record.guid,
134
+ projectId: record.projectId,
135
+ actorId: actorFrom(ctx)
136
+ });
137
+ }
138
+
139
+ async update(ctx: RequestContext, guid: string, patch: DefinitionRecordPatch): Promise<void> {
140
+ const row = patchToRow(patch);
141
+ if (Object.keys(row).length === 0) return;
142
+ if (ctx.userId) row.updated_by = ctx.userId;
143
+
144
+ const { data, error } = await this.clients
145
+ .forRequest(ctx)
146
+ .from('definitions')
147
+ .update(row)
148
+ .eq('guid', guid)
149
+ .is('deleted_at', null)
150
+ .select('guid');
151
+ if (error) throw mapError(error);
152
+ if (!data || data.length === 0) throw new ProviderError(`Definition '${guid}' not found`, 404);
153
+ }
154
+
155
+ async delete(ctx: RequestContext, guid: string): Promise<void> {
156
+ // Soft-delete (matches the local provider and spec §9). A retention
157
+ // sweep running as service-role hard-deletes later, which cascades
158
+ // versions + share_links via FK.
159
+ const stamp = auditSoftDelete(ctx, ctx.userId);
160
+ const row: Record<string, unknown> = {
161
+ deleted_at: stamp.deletedAt,
162
+ updated_at: stamp.updatedAt
163
+ };
164
+ if (stamp.updatedBy) row.updated_by = stamp.updatedBy;
165
+ const { error } = await this.clients
166
+ .forRequest(ctx)
167
+ .from('definitions')
168
+ .update(row)
169
+ .eq('guid', guid)
170
+ .is('deleted_at', null);
171
+ if (error) throw mapError(error);
172
+ await this.events.emit({
173
+ type: 'definition.deleted',
174
+ definitionId: guid,
175
+ actorId: actorFrom(ctx)
176
+ });
177
+ }
178
+
179
+ async incrementSolveCount(ctx: RequestContext, guid: string): Promise<void> {
180
+ const { error } = await this.clients.forRequest(ctx).rpc('increment_run_count', { g: guid });
181
+ if (error) throw mapError(error);
182
+ }
183
+
184
+ // ============================================================================
185
+ // Versions (spec §6)
186
+ // ============================================================================
187
+
188
+ async createVersion(ctx: RequestContext, version: DefinitionVersion): Promise<void> {
189
+ const { error } = await this.clients
190
+ .forRequest(ctx)
191
+ .from('definition_versions')
192
+ .insert(versionToRow(version));
193
+ if (error) throw mapError(error);
194
+ await this.events.emit({
195
+ type: 'definition_version.created',
196
+ versionId: version.id,
197
+ definitionId: version.definitionId,
198
+ actorId: actorFrom(ctx)
199
+ });
200
+ }
201
+
202
+ async listVersions(
203
+ ctx: RequestContext,
204
+ definitionId: string,
205
+ opts?: ListOptions
206
+ ): Promise<Page<DefinitionVersion>> {
207
+ const range = toRange(opts);
208
+ const { data, error, count } = await this.clients
209
+ .forRequest(ctx)
210
+ .from('definition_versions')
211
+ .select('*', { count: 'exact' })
212
+ .eq('definition_guid', definitionId)
213
+ .order('version_number', { ascending: false })
214
+ .range(range.from, range.to);
215
+ if (error) throw mapError(error);
216
+ const items = (data ?? []).map(rowToVersion);
217
+ return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
218
+ }
219
+
220
+ async getVersion(ctx: RequestContext, versionId: string): Promise<DefinitionVersion | null> {
221
+ const { data, error } = await this.clients
222
+ .forRequest(ctx)
223
+ .from('definition_versions')
224
+ .select('*')
225
+ .eq('id', versionId)
226
+ .maybeSingle();
227
+ if (error) throw mapError(error);
228
+ return data ? rowToVersion(data) : null;
229
+ }
230
+
231
+ async deleteVersion(ctx: RequestContext, versionId: string): Promise<void> {
232
+ // FK enforcement at the DB layer: live_version_id / draft_version_id
233
+ // are ON DELETE RESTRICT. If the version is referenced by either
234
+ // channel, Postgres raises 23503 → mapError → ProviderError(409).
235
+ const { error } = await this.clients
236
+ .forRequest(ctx)
237
+ .from('definition_versions')
238
+ .delete()
239
+ .eq('id', versionId);
240
+ if (error) throw mapError(error);
241
+ await this.events.emit({
242
+ type: 'definition_version.deleted',
243
+ versionId,
244
+ actorId: actorFrom(ctx)
245
+ });
246
+ }
247
+
248
+ async setLiveVersion(
249
+ ctx: RequestContext,
250
+ definitionId: string,
251
+ versionId: string
252
+ ): Promise<void> {
253
+ await this.repointChannel(ctx, definitionId, versionId, 'live_version_id');
254
+ // Only `live` advancement is the published-event trigger. Draft
255
+ // repointing is the editor's working pointer, not a publication signal.
256
+ await this.events.emit({
257
+ type: 'definition.published',
258
+ definitionId,
259
+ versionId,
260
+ actorId: actorFrom(ctx)
261
+ });
262
+ }
263
+
264
+ async setDraftVersion(
265
+ ctx: RequestContext,
266
+ definitionId: string,
267
+ versionId: string
268
+ ): Promise<void> {
269
+ await this.repointChannel(ctx, definitionId, versionId, 'draft_version_id');
270
+ }
271
+
272
+ async attachInitialVersion(
273
+ ctx: RequestContext,
274
+ definitionId: string,
275
+ versionId: string
276
+ ): Promise<void> {
277
+ const client = this.clients.forRequest(ctx);
278
+ // Validate the version belongs to this definition before repointing —
279
+ // matches `repointChannel` so the bootstrap surface enforces the same
280
+ // invariant.
281
+ const { data: version, error: vError } = await client
282
+ .from('definition_versions')
283
+ .select('id, definition_guid')
284
+ .eq('id', versionId)
285
+ .maybeSingle();
286
+ if (vError) throw mapError(vError);
287
+ if (!version || version.definition_guid !== definitionId) {
288
+ throw new ProviderError(`Version '${versionId}' not found for this definition`, 404);
289
+ }
290
+
291
+ // Single UPDATE — both channel pointers and status flip in one row
292
+ // write, so a mid-flight failure can't leave a half-promoted record.
293
+ const row: Record<string, unknown> = {
294
+ live_version_id: versionId,
295
+ draft_version_id: versionId,
296
+ status: 'draft'
297
+ };
298
+ if (ctx.userId) row.updated_by = ctx.userId;
299
+
300
+ const { data, error } = await client
301
+ .from('definitions')
302
+ .update(row)
303
+ .eq('guid', definitionId)
304
+ .is('deleted_at', null)
305
+ .select('guid');
306
+ if (error) throw mapError(error);
307
+ if (!data || data.length === 0) {
308
+ throw new ProviderError(`Definition '${definitionId}' not found`, 404);
309
+ }
310
+ // No `definition.published` event — see interface doc.
311
+ }
312
+
313
+ private async repointChannel(
314
+ ctx: RequestContext,
315
+ definitionId: string,
316
+ versionId: string,
317
+ column: 'live_version_id' | 'draft_version_id'
318
+ ): Promise<void> {
319
+ const client = this.clients.forRequest(ctx);
320
+ // Validate the version belongs to this definition before repointing.
321
+ const { data: version, error: vError } = await client
322
+ .from('definition_versions')
323
+ .select('id, definition_guid')
324
+ .eq('id', versionId)
325
+ .maybeSingle();
326
+ if (vError) throw mapError(vError);
327
+ if (!version || version.definition_guid !== definitionId) {
328
+ throw new ProviderError(`Version '${versionId}' not found for this definition`, 404);
329
+ }
330
+
331
+ const row: Record<string, unknown> = { [column]: versionId };
332
+ if (ctx.userId) row.updated_by = ctx.userId;
333
+ const { data, error } = await client
334
+ .from('definitions')
335
+ .update(row)
336
+ .eq('guid', definitionId)
337
+ .is('deleted_at', null)
338
+ .select('guid');
339
+ if (error) throw mapError(error);
340
+ if (!data || data.length === 0) {
341
+ throw new ProviderError(`Definition '${definitionId}' not found`, 404);
342
+ }
343
+ }
344
+ }
345
+
346
+ // ============================================================================
347
+ // Row ↔ domain mappers
348
+ // ============================================================================
349
+ interface DefinitionRow {
350
+ guid: string;
351
+ project_id: string;
352
+ owner_id: string;
353
+ created_by?: string | null;
354
+ updated_by?: string | null;
355
+ compute_server_id: string | null;
356
+ display_name: string;
357
+ description: string | null;
358
+ category: string | null;
359
+ tags: string[] | null;
360
+ cover_image: string | null;
361
+ status: DefinitionStatus;
362
+ run_count: number | string;
363
+ live_version_id: string | null;
364
+ draft_version_id: string | null;
365
+ created_at: string;
366
+ updated_at: string;
367
+ deleted_at?: string | null;
368
+ }
369
+
370
+ interface DefinitionVersionRow {
371
+ id: string;
372
+ definition_guid: string;
373
+ version_number: number;
374
+ file_ext: DefinitionFileExt;
375
+ file_key: string;
376
+ original_filename: string | null;
377
+ uploaded_by: string;
378
+ uploaded_at: string;
379
+ change_note?: string | null;
380
+ }
381
+
382
+ function rowToRecord(row: DefinitionRow): DefinitionRecord {
383
+ return {
384
+ guid: row.guid,
385
+ projectId: row.project_id,
386
+ ownerId: row.owner_id,
387
+ createdBy: row.created_by ?? row.owner_id,
388
+ updatedBy: row.updated_by ?? row.owner_id,
389
+ liveVersionId: row.live_version_id,
390
+ draftVersionId: row.draft_version_id,
391
+ computeServerId: row.compute_server_id ?? undefined,
392
+ displayName: row.display_name,
393
+ description: row.description ?? undefined,
394
+ category: row.category ?? undefined,
395
+ tags: row.tags ?? undefined,
396
+ coverImage: row.cover_image ?? undefined,
397
+ status: row.status,
398
+ solveCount: typeof row.run_count === 'string' ? Number(row.run_count) : row.run_count,
399
+ createdAt: row.created_at,
400
+ updatedAt: row.updated_at,
401
+ deletedAt: row.deleted_at ?? null
402
+ };
403
+ }
404
+
405
+ function recordToRow(r: DefinitionRecord): Record<string, unknown> {
406
+ const row: Record<string, unknown> = {
407
+ guid: r.guid,
408
+ project_id: r.projectId,
409
+ owner_id: r.ownerId,
410
+ created_by: r.createdBy,
411
+ updated_by: r.updatedBy,
412
+ live_version_id: r.liveVersionId,
413
+ draft_version_id: r.draftVersionId,
414
+ compute_server_id: r.computeServerId ?? null,
415
+ display_name: r.displayName,
416
+ description: r.description ?? null,
417
+ category: r.category ?? null,
418
+ cover_image: r.coverImage ?? null,
419
+ status: r.status,
420
+ run_count: r.solveCount,
421
+ created_at: r.createdAt,
422
+ updated_at: r.updatedAt,
423
+ deleted_at: r.deletedAt ?? null
424
+ };
425
+ if (r.tags !== undefined) row.tags = r.tags;
426
+ return row;
427
+ }
428
+
429
+ function patchToRow(patch: DefinitionRecordPatch): Record<string, unknown> {
430
+ const row: Record<string, unknown> = {};
431
+ if (patch.displayName !== undefined) row.display_name = patch.displayName;
432
+ if (patch.description !== undefined) row.description = patch.description;
433
+ if (patch.category !== undefined) row.category = patch.category;
434
+ if (patch.tags !== undefined) row.tags = patch.tags;
435
+ if (patch.coverImage !== undefined) row.cover_image = patch.coverImage;
436
+ if (patch.projectId !== undefined) row.project_id = patch.projectId;
437
+ if (patch.computeServerId !== undefined) row.compute_server_id = patch.computeServerId;
438
+ if (patch.status !== undefined) row.status = patch.status;
439
+ if (patch.ownerId !== undefined) row.owner_id = patch.ownerId;
440
+ return row;
441
+ }
442
+
443
+ function rowToVersion(row: DefinitionVersionRow): DefinitionVersion {
444
+ return {
445
+ id: row.id,
446
+ definitionId: row.definition_guid,
447
+ versionNumber: row.version_number,
448
+ fileExt: row.file_ext,
449
+ fileKey: row.file_key,
450
+ originalFilename: row.original_filename ?? undefined,
451
+ uploadedBy: row.uploaded_by,
452
+ uploadedAt: row.uploaded_at,
453
+ changeNote: row.change_note ?? undefined
454
+ };
455
+ }
456
+
457
+ function versionToRow(v: DefinitionVersion): Record<string, unknown> {
458
+ const row: Record<string, unknown> = {
459
+ id: v.id,
460
+ definition_guid: v.definitionId,
461
+ version_number: v.versionNumber,
462
+ file_ext: v.fileExt,
463
+ file_key: v.fileKey,
464
+ original_filename: v.originalFilename ?? null,
465
+ uploaded_by: v.uploadedBy,
466
+ uploaded_at: v.uploadedAt
467
+ };
468
+ if (v.changeNote !== undefined) row.change_note = v.changeNote;
469
+ return row;
470
+ }
471
+
472
+ function definitionOrderColumn(orderBy: DefinitionListOptions['orderBy'] | undefined): string {
473
+ switch (orderBy) {
474
+ case 'name':
475
+ return 'display_name';
476
+ case 'solveCount':
477
+ return 'run_count';
478
+ case 'updatedAt':
479
+ return 'updated_at';
480
+ case 'createdAt':
481
+ default:
482
+ return 'created_at';
483
+ }
484
+ }
485
+
486
+ interface PostgrestError {
487
+ code?: string;
488
+ message?: string;
489
+ }
490
+
491
+ function mapError(e: unknown): Error {
492
+ const pg = e as PostgrestError;
493
+ if (pg?.code === '23505') return new ProviderError(pg.message ?? 'Duplicate record', 409);
494
+ if (pg?.code === '23503') return new ProviderError(pg.message ?? 'Foreign key violation', 409);
495
+ if (e instanceof Error) return e;
496
+ if (e && typeof e === 'object') {
497
+ const obj = e as { message?: string; details?: string; hint?: string; code?: string };
498
+ const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
499
+ const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
500
+ Object.assign(err, obj);
501
+ return err;
502
+ }
503
+ return new Error(String(e));
504
+ }
@@ -0,0 +1,30 @@
1
+ import type { DomainEvent, IEventSink } from '@selvajs/platform';
2
+ import type { ClientBundle } from './client.js';
3
+
4
+ /**
5
+ * Persists every domain event to `public.audit_events`. Writes use the
6
+ * service-role client so RLS does not gate the audit trail — the sink runs
7
+ * as a system process attached to whichever store emitted the event.
8
+ *
9
+ * Per the `IEventSink` contract, `emit` MUST NOT throw. The user-facing
10
+ * mutation has already succeeded by the time we're here; a failed audit
11
+ * write is logged and swallowed so the response still completes.
12
+ */
13
+ export class SupabaseEventSink implements IEventSink {
14
+ constructor(private readonly clients: ClientBundle) {}
15
+
16
+ async emit(event: DomainEvent): Promise<void> {
17
+ try {
18
+ const { error } = await this.clients.serviceClient.from('audit_events').insert({
19
+ type: event.type,
20
+ actor_id: event.actorId,
21
+ data: event
22
+ });
23
+ if (error) {
24
+ console.error('[SupabaseEventSink] insert failed:', error.message, { event });
25
+ }
26
+ } catch (err) {
27
+ console.error('[SupabaseEventSink] unexpected error:', err, { event });
28
+ }
29
+ }
30
+ }