@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,547 @@
1
+ import type {
2
+ IOrgStore,
3
+ IEventSink,
4
+ Organization,
5
+ OrgMember,
6
+ OrgRole,
7
+ OrgPermission,
8
+ RequestContext,
9
+ ListOptions,
10
+ Page
11
+ } from '@selvajs/platform';
12
+ import {
13
+ DEFAULT_ORG_PERMISSIONS,
14
+ ProviderError,
15
+ auditSoftDelete,
16
+ actorFrom,
17
+ NoopEventSink
18
+ } from '@selvajs/platform';
19
+ import type { ClientBundle } from './client.js';
20
+ import { nextCursorFromRange, orderColumn, toRange } from './pagination.js';
21
+
22
+ /**
23
+ * Org + org-membership store backed by Postgres. The queries rely on RLS
24
+ * for per-user visibility; the service-role client bypasses those policies
25
+ * for admin paths (setup, janitor, tests).
26
+ *
27
+ * `createOrg` atomically seeds the creator as `owner` with every
28
+ * `OrgPermission` — otherwise under user-scoped RLS the creator would
29
+ * immediately lose visibility of their own org.
30
+ */
31
+ export class SupabaseOrgStore implements IOrgStore {
32
+ private readonly events: IEventSink;
33
+
34
+ constructor(
35
+ private readonly clients: ClientBundle,
36
+ events: IEventSink = new NoopEventSink()
37
+ ) {
38
+ this.events = events;
39
+ }
40
+
41
+ async listOrgs(ctx: RequestContext, opts?: ListOptions): Promise<Page<Organization>> {
42
+ const range = toRange(opts);
43
+ const direction = opts?.orderDir ?? 'desc';
44
+ const { data, error, count } = await this.clients
45
+ .forRequest(ctx)
46
+ .from('orgs')
47
+ .select('*', { count: 'exact' })
48
+ .is('deleted_at', null)
49
+ .order(orderColumn(opts?.orderBy), { ascending: direction === 'asc' })
50
+ .range(range.from, range.to);
51
+ if (error) throw mapError(error);
52
+ const items = (data ?? []).map(rowToOrg);
53
+ return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
54
+ }
55
+
56
+ async getOrg(ctx: RequestContext, id: string): Promise<Organization | null> {
57
+ const { data, error } = await this.clients
58
+ .forRequest(ctx)
59
+ .from('orgs')
60
+ .select('*')
61
+ .eq('id', id)
62
+ .is('deleted_at', null)
63
+ .maybeSingle();
64
+ if (error) throw mapError(error);
65
+ return data ? rowToOrg(data) : null;
66
+ }
67
+
68
+ async getOrgBySlug(ctx: RequestContext, slug: string): Promise<Organization | null> {
69
+ const { data, error } = await this.clients
70
+ .forRequest(ctx)
71
+ .from('orgs')
72
+ .select('*')
73
+ .eq('slug', slug)
74
+ .is('deleted_at', null)
75
+ .maybeSingle();
76
+ if (error) throw mapError(error);
77
+ return data ? rowToOrg(data) : null;
78
+ }
79
+
80
+ async createOrg(ctx: RequestContext, org: Organization): Promise<void> {
81
+ const client = this.clients.forRequest(ctx);
82
+ const { error } = await client.from('orgs').insert(orgToRow(org));
83
+ if (error) throw mapError(error);
84
+
85
+ // Seed owner membership so user-scoped queries can see the org post-create.
86
+ // Service-role bypasses RLS, but the same code path runs for user-scoped
87
+ // callers — the membership row is what makes them visible to themselves.
88
+ // Upsert so a stale soft-deleted row from a prior org with the same id
89
+ // is reactivated rather than blocking creation.
90
+ const { error: memberError } = await client.from('org_members').upsert(
91
+ {
92
+ org_id: org.id,
93
+ user_id: org.ownerId,
94
+ role: 'owner',
95
+ permissions: [...DEFAULT_ORG_PERMISSIONS.owner],
96
+ joined_at: new Date().toISOString(),
97
+ deleted_at: null
98
+ },
99
+ { onConflict: 'org_id,user_id' }
100
+ );
101
+ if (memberError) throw mapError(memberError);
102
+ await this.events.emit({ type: 'org.created', orgId: org.id, actorId: actorFrom(ctx) });
103
+ }
104
+
105
+ async updateOrg(
106
+ ctx: RequestContext,
107
+ id: string,
108
+ patch: Partial<Pick<Organization, 'name' | 'slug'>>
109
+ ): Promise<void> {
110
+ const row: Record<string, unknown> = {};
111
+ if (patch.name !== undefined) row.name = patch.name;
112
+ if (patch.slug !== undefined) row.slug = patch.slug;
113
+ if (Object.keys(row).length === 0) return;
114
+ // `updated_at` is set by the trg_orgs_updated_at trigger; `updated_by`
115
+ // is not, so stamp it here. ctx.userId can be empty in system contexts —
116
+ // the DB FK is `on delete set null` so passing empty would fail; let it
117
+ // fall back to whatever's already on the row by omitting in that case.
118
+ if (ctx.userId) row.updated_by = ctx.userId;
119
+
120
+ const { error, data } = await this.clients
121
+ .forRequest(ctx)
122
+ .from('orgs')
123
+ .update(row)
124
+ .eq('id', id)
125
+ .is('deleted_at', null)
126
+ .select('id');
127
+ if (error) throw mapError(error);
128
+ if (!data || data.length === 0) throw new ProviderError(`Org '${id}' not found`, 404);
129
+ }
130
+
131
+ async deleteOrg(ctx: RequestContext, id: string): Promise<void> {
132
+ // §9 soft-delete with cascade. Mirrors LocalOrgStore: org → org_members,
133
+ // projects → project_members, definitions. Hard delete is reserved for
134
+ // the background janitor; user-facing deletes preserve the audit trail.
135
+ const client = this.clients.forRequest(ctx);
136
+ const stamp = auditSoftDelete(ctx, ctx.userId);
137
+ const stampRow = stampToRow(stamp);
138
+
139
+ // Org itself.
140
+ const { error: orgErr, data: orgData } = await client
141
+ .from('orgs')
142
+ .update(stampRow)
143
+ .eq('id', id)
144
+ .is('deleted_at', null)
145
+ .select('id');
146
+ if (orgErr) throw mapError(orgErr);
147
+ if (!orgData || orgData.length === 0) throw new ProviderError(`Org '${id}' not found`, 404);
148
+
149
+ // Cascade: org_members.
150
+ const { error: omErr } = await client
151
+ .from('org_members')
152
+ .update(stampRow)
153
+ .eq('org_id', id)
154
+ .is('deleted_at', null);
155
+ if (omErr) throw mapError(omErr);
156
+
157
+ // Cascade: projects in this org. Fetch IDs first so we can cascade to
158
+ // project_members and definitions in app code (Postgres FK CASCADE only
159
+ // fires on hard DELETE, which we're not doing here).
160
+ const { data: orgProjects, error: projFetchErr } = await client
161
+ .from('projects')
162
+ .select('id')
163
+ .eq('org_id', id)
164
+ .is('deleted_at', null);
165
+ if (projFetchErr) throw mapError(projFetchErr);
166
+ const projectIds = (orgProjects ?? []).map((p) => p.id as string);
167
+
168
+ const { error: projErr } = await client
169
+ .from('projects')
170
+ .update(stampRow)
171
+ .eq('org_id', id)
172
+ .is('deleted_at', null);
173
+ if (projErr) throw mapError(projErr);
174
+
175
+ if (projectIds.length > 0) {
176
+ const { error: pmErr } = await client
177
+ .from('project_members')
178
+ .update(stampRow)
179
+ .in('project_id', projectIds)
180
+ .is('deleted_at', null);
181
+ if (pmErr) throw mapError(pmErr);
182
+
183
+ const { error: defErr } = await client
184
+ .from('definitions')
185
+ .update(stampRow)
186
+ .in('project_id', projectIds)
187
+ .is('deleted_at', null);
188
+ if (defErr) throw mapError(defErr);
189
+ }
190
+
191
+ // Hard-delete tables that have no `deleted_at` column. SQL CASCADE on
192
+ // the org FK only fires for hard org deletes; we soft-delete, so we
193
+ // clean these up here. Pending invites to a dead org are unredeemable;
194
+ // stale compute config is operational state with no audit need.
195
+ const { error: invErr } = await client.from('invites').delete().eq('org_id', id);
196
+ if (invErr) throw mapError(invErr);
197
+
198
+ const { error: cdErr } = await client
199
+ .from('compute_server_org_defaults')
200
+ .delete()
201
+ .eq('org_id', id);
202
+ if (cdErr) throw mapError(cdErr);
203
+
204
+ const { error: shErr } = await client.from('compute_server_shares').delete().eq('org_id', id);
205
+ if (shErr) throw mapError(shErr);
206
+
207
+ const { error: csErr } = await client
208
+ .from('compute_servers')
209
+ .delete()
210
+ .eq('scope', 'org')
211
+ .eq('owner_org_id', id);
212
+ if (csErr) throw mapError(csErr);
213
+
214
+ await this.events.emit({ type: 'org.deleted', orgId: id, actorId: actorFrom(ctx) });
215
+ }
216
+
217
+ // ============================================================================
218
+ // Org members
219
+ // ============================================================================
220
+ async listOrgMembers(
221
+ ctx: RequestContext,
222
+ orgId: string,
223
+ opts?: ListOptions
224
+ ): Promise<Page<OrgMember>> {
225
+ const range = toRange(opts);
226
+ const { data, error, count } = await this.clients
227
+ .forRequest(ctx)
228
+ .from('org_members')
229
+ .select('*', { count: 'exact' })
230
+ .eq('org_id', orgId)
231
+ .is('deleted_at', null)
232
+ .order('joined_at', { ascending: (opts?.orderDir ?? 'desc') === 'asc' })
233
+ .range(range.from, range.to);
234
+ if (error) throw mapError(error);
235
+ const items = (data ?? []).map(rowToOrgMember);
236
+ return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
237
+ }
238
+
239
+ async getOrgMember(
240
+ ctx: RequestContext,
241
+ orgId: string,
242
+ userId: string
243
+ ): Promise<OrgMember | null> {
244
+ const { data, error } = await this.clients
245
+ .forRequest(ctx)
246
+ .from('org_members')
247
+ .select('*')
248
+ .eq('org_id', orgId)
249
+ .eq('user_id', userId)
250
+ .is('deleted_at', null)
251
+ .maybeSingle();
252
+ if (error) throw mapError(error);
253
+ return data ? rowToOrgMember(data) : null;
254
+ }
255
+
256
+ async findUserMembership(
257
+ ctx: RequestContext,
258
+ userId: string
259
+ ): Promise<{ org: Organization; member: OrgMember } | null> {
260
+ // One round-trip: join org_members → orgs and filter live rows on
261
+ // both sides. PostgREST `!inner` on the join keeps it a single SELECT,
262
+ // avoiding the listOrgs + getOrgMember-per-org N+1 the bootstrap path
263
+ // used to do. RLS still scopes by ctx; service-role sees all.
264
+ const { data, error } = await this.clients
265
+ .forRequest(ctx)
266
+ .from('org_members')
267
+ .select('*, org:orgs!inner(*)')
268
+ .eq('user_id', userId)
269
+ .is('deleted_at', null)
270
+ .is('org.deleted_at', null)
271
+ .order('joined_at', { ascending: true })
272
+ .limit(1)
273
+ .maybeSingle();
274
+ if (error) throw mapError(error);
275
+ if (!data) return null;
276
+
277
+ // PostgREST embeds the related row as `org` per the alias above. Strip
278
+ // it off the member row before mapping so OrgMember stays clean.
279
+ const row = data as OrgMemberRow & { org: OrgRow | OrgRow[] };
280
+ const orgRow = Array.isArray(row.org) ? row.org[0] : row.org;
281
+ if (!orgRow) return null;
282
+ const { org: _omit, ...memberRow } = row;
283
+ return {
284
+ org: rowToOrg(orgRow),
285
+ member: rowToOrgMember(memberRow as OrgMemberRow)
286
+ };
287
+ }
288
+
289
+ async addOrgMember(ctx: RequestContext, member: OrgMember): Promise<void> {
290
+ // Upsert so a prior soft-deleted row is reactivated rather than throwing
291
+ // a duplicate-key error. Mirrors LocalOrgStore.addOrgMember.
292
+ const row: Record<string, unknown> = {
293
+ ...memberToRow(member),
294
+ deleted_at: null
295
+ };
296
+ if (ctx.userId) row.updated_by = ctx.userId;
297
+ const { error } = await this.clients
298
+ .forRequest(ctx)
299
+ .from('org_members')
300
+ .upsert(row, { onConflict: 'org_id,user_id' });
301
+ if (error) throw mapError(error);
302
+ await this.events.emit({
303
+ type: 'org_member.added',
304
+ orgId: member.orgId,
305
+ userId: member.userId,
306
+ actorId: actorFrom(ctx)
307
+ });
308
+ }
309
+
310
+ async updateOrgMemberRole(
311
+ ctx: RequestContext,
312
+ orgId: string,
313
+ userId: string,
314
+ role: OrgRole
315
+ ): Promise<void> {
316
+ // Role change re-seeds default permissions. Matches LocalOrgStore.
317
+ const row: Record<string, unknown> = {
318
+ role,
319
+ permissions: [...DEFAULT_ORG_PERMISSIONS[role]]
320
+ };
321
+ if (ctx.userId) row.updated_by = ctx.userId;
322
+ const { data, error } = await this.clients
323
+ .forRequest(ctx)
324
+ .from('org_members')
325
+ .update(row)
326
+ .eq('org_id', orgId)
327
+ .eq('user_id', userId)
328
+ .is('deleted_at', null)
329
+ .select('user_id');
330
+ if (error) throw mapError(error);
331
+ if (!data || data.length === 0)
332
+ throw new ProviderError(`Org member '${userId}' not found`, 404);
333
+ await this.events.emit({
334
+ type: 'org_member.role_changed',
335
+ orgId,
336
+ userId,
337
+ role,
338
+ actorId: actorFrom(ctx)
339
+ });
340
+ }
341
+
342
+ async updateOrgMemberPermissions(
343
+ ctx: RequestContext,
344
+ orgId: string,
345
+ userId: string,
346
+ permissions: readonly OrgPermission[]
347
+ ): Promise<void> {
348
+ // Replace permissions only. Matches LocalOrgStore.updateOrgMemberPermissions —
349
+ // distinct from role change so callers can grant a finer-grained set without
350
+ // re-seeding defaults from the role.
351
+ const row: Record<string, unknown> = { permissions: [...permissions] };
352
+ if (ctx.userId) row.updated_by = ctx.userId;
353
+ const { data, error } = await this.clients
354
+ .forRequest(ctx)
355
+ .from('org_members')
356
+ .update(row)
357
+ .eq('org_id', orgId)
358
+ .eq('user_id', userId)
359
+ .is('deleted_at', null)
360
+ .select('user_id');
361
+ if (error) throw mapError(error);
362
+ if (!data || data.length === 0)
363
+ throw new ProviderError(`Org member '${userId}' not found`, 404);
364
+ await this.events.emit({
365
+ type: 'org_member.permissions_changed',
366
+ orgId,
367
+ userId,
368
+ permissions: [...permissions],
369
+ actorId: actorFrom(ctx)
370
+ });
371
+ }
372
+
373
+ async removeOrgMember(ctx: RequestContext, orgId: string, userId: string): Promise<void> {
374
+ // §9 cascade — losing org membership ends every project membership scoped
375
+ // to that tenant. Soft-delete the org_member row, then cascade-soft-delete
376
+ // the user's project_members for any project in this org. Run the project
377
+ // cascade BEFORE the org_member update so RLS policies that gate
378
+ // project_members on org_member existence still pass for the duration.
379
+ const client = this.clients.forRequest(ctx);
380
+ const stamp = auditSoftDelete(ctx, ctx.userId);
381
+ const stampRow = stampToRow(stamp);
382
+
383
+ const { data: orgProjects, error: projError } = await client
384
+ .from('projects')
385
+ .select('id')
386
+ .eq('org_id', orgId)
387
+ .is('deleted_at', null);
388
+ if (projError) throw mapError(projError);
389
+
390
+ const projectIds = (orgProjects ?? []).map((p) => p.id as string);
391
+ if (projectIds.length > 0) {
392
+ const { error: pmError } = await client
393
+ .from('project_members')
394
+ .update(stampRow)
395
+ .in('project_id', projectIds)
396
+ .eq('user_id', userId)
397
+ .is('deleted_at', null);
398
+ if (pmError) throw mapError(pmError);
399
+ }
400
+
401
+ const { error } = await client
402
+ .from('org_members')
403
+ .update(stampRow)
404
+ .eq('org_id', orgId)
405
+ .eq('user_id', userId)
406
+ .is('deleted_at', null);
407
+ if (error) throw mapError(error);
408
+ await this.events.emit({
409
+ type: 'org_member.removed',
410
+ orgId,
411
+ userId,
412
+ actorId: actorFrom(ctx)
413
+ });
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Build a row payload from an audit-soft-delete stamp. `updated_by` only
419
+ * goes into the row when ctx.userId is set — passing `''` would violate the
420
+ * `references auth.users(id)` FK on system contexts. The DB trigger sets
421
+ * `updated_at`, but for soft-delete we set it explicitly so it matches
422
+ * `deleted_at` (single timestamp for the deletion event).
423
+ */
424
+ function stampToRow(stamp: {
425
+ updatedAt: string;
426
+ updatedBy: string;
427
+ deletedAt: string;
428
+ }): Record<string, unknown> {
429
+ const row: Record<string, unknown> = {
430
+ deleted_at: stamp.deletedAt,
431
+ updated_at: stamp.updatedAt
432
+ };
433
+ if (stamp.updatedBy) row.updated_by = stamp.updatedBy;
434
+ return row;
435
+ }
436
+
437
+ // ============================================================================
438
+ // Row ↔ domain mappers
439
+ // ============================================================================
440
+ //
441
+ // Audit columns (`created_by` / `updated_by`) FK to `auth.users(id)` with
442
+ // `ON DELETE SET NULL`, so they can legitimately become NULL when the
443
+ // referenced user is deleted (spec §8). Mappers fall back to `owner_id` /
444
+ // `user_id` so the domain type stays non-nullable; UIs that care about
445
+ // creator-vs-owner attribution should render "Deleted user" at that layer.
446
+
447
+ interface OrgRow {
448
+ id: string;
449
+ name: string;
450
+ slug: string;
451
+ owner_id: string;
452
+ created_by?: string | null;
453
+ updated_by?: string | null;
454
+ created_at: string;
455
+ updated_at: string;
456
+ deleted_at?: string | null;
457
+ }
458
+
459
+ interface OrgMemberRow {
460
+ org_id: string;
461
+ user_id: string;
462
+ role: OrgRole;
463
+ permissions: string[];
464
+ joined_at: string;
465
+ updated_at?: string | null;
466
+ updated_by?: string | null;
467
+ deleted_at?: string | null;
468
+ }
469
+
470
+ function rowToOrg(row: OrgRow): Organization {
471
+ return {
472
+ id: row.id,
473
+ name: row.name,
474
+ slug: row.slug,
475
+ ownerId: row.owner_id,
476
+ createdBy: row.created_by ?? row.owner_id,
477
+ updatedBy: row.updated_by ?? row.owner_id,
478
+ createdAt: row.created_at,
479
+ updatedAt: row.updated_at,
480
+ deletedAt: row.deleted_at ?? null
481
+ };
482
+ }
483
+
484
+ function orgToRow(org: Organization): OrgRow {
485
+ return {
486
+ id: org.id,
487
+ name: org.name,
488
+ slug: org.slug,
489
+ owner_id: org.ownerId,
490
+ created_by: org.createdBy,
491
+ updated_by: org.updatedBy,
492
+ created_at: org.createdAt,
493
+ updated_at: org.updatedAt,
494
+ deleted_at: org.deletedAt ?? null
495
+ };
496
+ }
497
+
498
+ function rowToOrgMember(row: OrgMemberRow): OrgMember {
499
+ return {
500
+ orgId: row.org_id,
501
+ userId: row.user_id,
502
+ role: row.role,
503
+ permissions: (row.permissions ?? []) as OrgMember['permissions'],
504
+ joinedAt: row.joined_at,
505
+ updatedAt: row.updated_at ?? row.joined_at,
506
+ updatedBy: row.updated_by ?? row.user_id,
507
+ deletedAt: row.deleted_at ?? null
508
+ };
509
+ }
510
+
511
+ function memberToRow(m: OrgMember): OrgMemberRow {
512
+ return {
513
+ org_id: m.orgId,
514
+ user_id: m.userId,
515
+ role: m.role,
516
+ permissions: m.permissions ?? [],
517
+ joined_at: m.joinedAt
518
+ };
519
+ }
520
+
521
+ // ============================================================================
522
+ // Error translation
523
+ // ============================================================================
524
+ interface PostgrestError {
525
+ code?: string;
526
+ message?: string;
527
+ details?: string;
528
+ }
529
+
530
+ function mapError(e: unknown): Error {
531
+ const pg = e as PostgrestError;
532
+ if (pg?.code === '23505') {
533
+ return new ProviderError(pg.message ?? 'Duplicate record', 409);
534
+ }
535
+ if (pg?.code === '23503') {
536
+ return new ProviderError(pg.message ?? 'Foreign key violation', 409);
537
+ }
538
+ if (e instanceof Error) return e;
539
+ if (e && typeof e === 'object') {
540
+ const obj = e as { message?: string; details?: string; hint?: string; code?: string };
541
+ const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
542
+ const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
543
+ Object.assign(err, obj);
544
+ return err;
545
+ }
546
+ return new Error(String(e));
547
+ }