@selvajs/supabase-provider 0.14.3 → 0.14.4-beta.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.
- package/dist/auth/SupabaseAuthProvider.d.ts +57 -0
- package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -1
- package/dist/auth/SupabaseAuthProvider.js +116 -4
- package/dist/auth/SupabaseAuthProvider.js.map +1 -1
- package/dist/auth/__tests__/token-verification.test.d.ts +13 -0
- package/dist/auth/__tests__/token-verification.test.d.ts.map +1 -0
- package/dist/auth/__tests__/token-verification.test.js +135 -0
- package/dist/auth/__tests__/token-verification.test.js.map +1 -0
- package/dist/data/SupabaseComputeServerStore.d.ts +34 -2
- package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -1
- package/dist/data/SupabaseComputeServerStore.js +146 -39
- package/dist/data/SupabaseComputeServerStore.js.map +1 -1
- package/dist/data/SupabaseDataProvider.d.ts +5 -2
- package/dist/data/SupabaseDataProvider.d.ts.map +1 -1
- package/dist/data/SupabaseDataProvider.js +24 -18
- package/dist/data/SupabaseDataProvider.js.map +1 -1
- package/dist/data/SupabaseDefinitionStore.d.ts +2 -1
- package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -1
- package/dist/data/SupabaseDefinitionStore.js +68 -67
- package/dist/data/SupabaseDefinitionStore.js.map +1 -1
- package/dist/data/SupabaseInviteStore.d.ts.map +1 -1
- package/dist/data/SupabaseInviteStore.js +11 -22
- package/dist/data/SupabaseInviteStore.js.map +1 -1
- package/dist/data/SupabaseOrgStore.d.ts.map +1 -1
- package/dist/data/SupabaseOrgStore.js +49 -86
- package/dist/data/SupabaseOrgStore.js.map +1 -1
- package/dist/data/SupabaseProjectStore.d.ts.map +1 -1
- package/dist/data/SupabaseProjectStore.js +51 -70
- package/dist/data/SupabaseProjectStore.js.map +1 -1
- package/dist/data/SupabaseShareLinkStore.d.ts.map +1 -1
- package/dist/data/SupabaseShareLinkStore.js +13 -27
- package/dist/data/SupabaseShareLinkStore.js.map +1 -1
- package/dist/data/__tests__/compute-server-conformance.test.js +6 -1
- package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -1
- package/dist/data/__tests__/compute-server-encryption.test.d.ts +12 -0
- package/dist/data/__tests__/compute-server-encryption.test.d.ts.map +1 -0
- package/dist/data/__tests__/compute-server-encryption.test.js +143 -0
- package/dist/data/__tests__/compute-server-encryption.test.js.map +1 -0
- package/dist/data/__tests__/share-link-concurrency.test.d.ts +22 -0
- package/dist/data/__tests__/share-link-concurrency.test.d.ts.map +1 -0
- package/dist/data/__tests__/share-link-concurrency.test.js +115 -0
- package/dist/data/__tests__/share-link-concurrency.test.js.map +1 -0
- package/dist/data/client.d.ts +35 -3
- package/dist/data/client.d.ts.map +1 -1
- package/dist/data/client.js +67 -16
- package/dist/data/client.js.map +1 -1
- package/dist/data/errors.d.ts +2 -0
- package/dist/data/errors.d.ts.map +1 -0
- package/dist/data/errors.js +23 -0
- package/dist/data/errors.js.map +1 -0
- package/dist/data/index.d.ts +7 -2
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +5 -1
- package/dist/data/index.js.map +1 -1
- package/dist/data/query.d.ts +20 -0
- package/dist/data/query.d.ts.map +1 -0
- package/dist/data/query.js +13 -0
- package/dist/data/query.js.map +1 -0
- package/dist/data/rowStamp.d.ts +38 -0
- package/dist/data/rowStamp.d.ts.map +1 -0
- package/dist/data/rowStamp.js +43 -0
- package/dist/data/rowStamp.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/auth/SupabaseAuthProvider.ts +140 -4
- package/src/auth/__tests__/token-verification.test.ts +158 -0
- package/src/data/SupabaseComputeServerStore.ts +163 -43
- package/src/data/SupabaseDataProvider.ts +35 -15
- package/src/data/SupabaseDefinitionStore.ts +73 -66
- package/src/data/SupabaseInviteStore.ts +13 -25
- package/src/data/SupabaseOrgStore.ts +51 -95
- package/src/data/SupabaseProjectStore.ts +55 -78
- package/src/data/SupabaseShareLinkStore.ts +15 -30
- package/src/data/__tests__/compute-server-conformance.test.ts +7 -1
- package/src/data/__tests__/compute-server-encryption.test.ts +173 -0
- package/src/data/__tests__/share-link-concurrency.test.ts +124 -0
- package/src/data/client.ts +95 -20
- package/src/data/errors.ts +45 -0
- package/src/data/index.ts +19 -2
- package/src/data/query.ts +22 -0
- package/src/data/rowStamp.ts +48 -0
- package/src/index.ts +20 -2
- package/supabase/migrations/20260711120000_selva_definition_schema_version.sql +18 -0
|
@@ -12,12 +12,20 @@ import type {
|
|
|
12
12
|
import {
|
|
13
13
|
DEFAULT_ORG_PERMISSIONS,
|
|
14
14
|
ProviderError,
|
|
15
|
-
auditSoftDelete,
|
|
16
15
|
actorFrom,
|
|
17
16
|
NoopEventSink
|
|
18
17
|
} from '@selvajs/platform';
|
|
19
18
|
import type { ClientBundle } from './client.js';
|
|
19
|
+
import { mapPostgrestError } from './errors.js';
|
|
20
20
|
import { nextCursorFromRange, orderColumn, toRange } from './pagination.js';
|
|
21
|
+
import { stampSoftDelete, stampUpdate } from './rowStamp.js';
|
|
22
|
+
|
|
23
|
+
/** Explicit column list for `orgs` — every field `rowToOrg` consumes. */
|
|
24
|
+
const ORG_COLUMNS =
|
|
25
|
+
'id, name, slug, owner_id, assets, created_by, updated_by, created_at, updated_at, deleted_at';
|
|
26
|
+
/** Explicit column list for `org_members` — every field `rowToOrgMember` consumes. */
|
|
27
|
+
const ORG_MEMBER_COLUMNS =
|
|
28
|
+
'org_id, user_id, role, permissions, joined_at, updated_at, updated_by, deleted_at';
|
|
21
29
|
|
|
22
30
|
/**
|
|
23
31
|
* Org + org-membership store backed by Postgres. The queries rely on RLS
|
|
@@ -44,11 +52,11 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
44
52
|
const { data, error, count } = await this.clients
|
|
45
53
|
.forRequest(ctx)
|
|
46
54
|
.from('orgs')
|
|
47
|
-
.select(
|
|
55
|
+
.select(ORG_COLUMNS, { count: 'exact' })
|
|
48
56
|
.is('deleted_at', null)
|
|
49
57
|
.order(orderColumn(opts?.orderBy), { ascending: direction === 'asc' })
|
|
50
58
|
.range(range.from, range.to);
|
|
51
|
-
if (error) throw
|
|
59
|
+
if (error) throw mapPostgrestError(error);
|
|
52
60
|
const items = (data ?? []).map(rowToOrg);
|
|
53
61
|
return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
|
|
54
62
|
}
|
|
@@ -57,11 +65,11 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
57
65
|
const { data, error } = await this.clients
|
|
58
66
|
.forRequest(ctx)
|
|
59
67
|
.from('orgs')
|
|
60
|
-
.select(
|
|
68
|
+
.select(ORG_COLUMNS)
|
|
61
69
|
.eq('id', id)
|
|
62
70
|
.is('deleted_at', null)
|
|
63
71
|
.maybeSingle();
|
|
64
|
-
if (error) throw
|
|
72
|
+
if (error) throw mapPostgrestError(error);
|
|
65
73
|
return data ? rowToOrg(data) : null;
|
|
66
74
|
}
|
|
67
75
|
|
|
@@ -69,18 +77,18 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
69
77
|
const { data, error } = await this.clients
|
|
70
78
|
.forRequest(ctx)
|
|
71
79
|
.from('orgs')
|
|
72
|
-
.select(
|
|
80
|
+
.select(ORG_COLUMNS)
|
|
73
81
|
.eq('slug', slug)
|
|
74
82
|
.is('deleted_at', null)
|
|
75
83
|
.maybeSingle();
|
|
76
|
-
if (error) throw
|
|
84
|
+
if (error) throw mapPostgrestError(error);
|
|
77
85
|
return data ? rowToOrg(data) : null;
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
async createOrg(ctx: RequestContext, org: Organization): Promise<void> {
|
|
81
89
|
const client = this.clients.forRequest(ctx);
|
|
82
90
|
const { error } = await client.from('orgs').insert(orgToRow(org));
|
|
83
|
-
if (error) throw
|
|
91
|
+
if (error) throw mapPostgrestError(error);
|
|
84
92
|
|
|
85
93
|
// Seed owner membership so user-scoped queries can see the org post-create.
|
|
86
94
|
// Service-role bypasses RLS, but the same code path runs for user-scoped
|
|
@@ -98,7 +106,7 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
98
106
|
},
|
|
99
107
|
{ onConflict: 'org_id,user_id' }
|
|
100
108
|
);
|
|
101
|
-
if (memberError) throw
|
|
109
|
+
if (memberError) throw mapPostgrestError(memberError);
|
|
102
110
|
await this.events.emit({ type: 'org.created', orgId: org.id, actorId: actorFrom(ctx) });
|
|
103
111
|
}
|
|
104
112
|
|
|
@@ -113,11 +121,10 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
113
121
|
// Callers pass the full merged map; store it wholesale (JSONB column).
|
|
114
122
|
if (patch.assets !== undefined) row.assets = patch.assets;
|
|
115
123
|
if (Object.keys(row).length === 0) return;
|
|
116
|
-
// `updated_at` is set by the trg_orgs_updated_at trigger; `
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
if (ctx.userId) row.updated_by = ctx.userId;
|
|
124
|
+
// `updated_at` is set by the trg_orgs_updated_at trigger; `stampUpdate`
|
|
125
|
+
// only adds `updated_by`, and only when ctx.userId is set (empty on
|
|
126
|
+
// system contexts would violate the `on delete set null` FK).
|
|
127
|
+
Object.assign(row, stampUpdate(ctx));
|
|
121
128
|
|
|
122
129
|
const { error, data } = await this.clients
|
|
123
130
|
.forRequest(ctx)
|
|
@@ -126,7 +133,7 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
126
133
|
.eq('id', id)
|
|
127
134
|
.is('deleted_at', null)
|
|
128
135
|
.select('id');
|
|
129
|
-
if (error) throw
|
|
136
|
+
if (error) throw mapPostgrestError(error);
|
|
130
137
|
if (!data || data.length === 0) throw new ProviderError(`Org '${id}' not found`, 404);
|
|
131
138
|
}
|
|
132
139
|
|
|
@@ -135,8 +142,7 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
135
142
|
// projects → project_members, definitions. Hard delete is reserved for
|
|
136
143
|
// the background janitor; user-facing deletes preserve the audit trail.
|
|
137
144
|
const client = this.clients.forRequest(ctx);
|
|
138
|
-
const
|
|
139
|
-
const stampRow = stampToRow(stamp);
|
|
145
|
+
const stampRow = stampSoftDelete(ctx);
|
|
140
146
|
|
|
141
147
|
// Org itself.
|
|
142
148
|
const { error: orgErr, data: orgData } = await client
|
|
@@ -145,7 +151,7 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
145
151
|
.eq('id', id)
|
|
146
152
|
.is('deleted_at', null)
|
|
147
153
|
.select('id');
|
|
148
|
-
if (orgErr) throw
|
|
154
|
+
if (orgErr) throw mapPostgrestError(orgErr);
|
|
149
155
|
if (!orgData || orgData.length === 0) throw new ProviderError(`Org '${id}' not found`, 404);
|
|
150
156
|
|
|
151
157
|
// Cascade: org_members.
|
|
@@ -154,7 +160,7 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
154
160
|
.update(stampRow)
|
|
155
161
|
.eq('org_id', id)
|
|
156
162
|
.is('deleted_at', null);
|
|
157
|
-
if (omErr) throw
|
|
163
|
+
if (omErr) throw mapPostgrestError(omErr);
|
|
158
164
|
|
|
159
165
|
// Cascade: projects in this org. Fetch IDs first so we can cascade to
|
|
160
166
|
// project_members and definitions in app code (Postgres FK CASCADE only
|
|
@@ -164,7 +170,7 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
164
170
|
.select('id')
|
|
165
171
|
.eq('org_id', id)
|
|
166
172
|
.is('deleted_at', null);
|
|
167
|
-
if (projFetchErr) throw
|
|
173
|
+
if (projFetchErr) throw mapPostgrestError(projFetchErr);
|
|
168
174
|
const projectIds = (orgProjects ?? []).map((p) => p.id as string);
|
|
169
175
|
|
|
170
176
|
const { error: projErr } = await client
|
|
@@ -172,7 +178,7 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
172
178
|
.update(stampRow)
|
|
173
179
|
.eq('org_id', id)
|
|
174
180
|
.is('deleted_at', null);
|
|
175
|
-
if (projErr) throw
|
|
181
|
+
if (projErr) throw mapPostgrestError(projErr);
|
|
176
182
|
|
|
177
183
|
if (projectIds.length > 0) {
|
|
178
184
|
const { error: pmErr } = await client
|
|
@@ -180,14 +186,14 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
180
186
|
.update(stampRow)
|
|
181
187
|
.in('project_id', projectIds)
|
|
182
188
|
.is('deleted_at', null);
|
|
183
|
-
if (pmErr) throw
|
|
189
|
+
if (pmErr) throw mapPostgrestError(pmErr);
|
|
184
190
|
|
|
185
191
|
const { error: defErr } = await client
|
|
186
192
|
.from('definitions')
|
|
187
193
|
.update(stampRow)
|
|
188
194
|
.in('project_id', projectIds)
|
|
189
195
|
.is('deleted_at', null);
|
|
190
|
-
if (defErr) throw
|
|
196
|
+
if (defErr) throw mapPostgrestError(defErr);
|
|
191
197
|
}
|
|
192
198
|
|
|
193
199
|
// Hard-delete tables that have no `deleted_at` column. SQL CASCADE on
|
|
@@ -195,23 +201,23 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
195
201
|
// clean these up here. Pending invites to a dead org are unredeemable;
|
|
196
202
|
// stale compute config is operational state with no audit need.
|
|
197
203
|
const { error: invErr } = await client.from('invites').delete().eq('org_id', id);
|
|
198
|
-
if (invErr) throw
|
|
204
|
+
if (invErr) throw mapPostgrestError(invErr);
|
|
199
205
|
|
|
200
206
|
const { error: cdErr } = await client
|
|
201
207
|
.from('compute_server_org_defaults')
|
|
202
208
|
.delete()
|
|
203
209
|
.eq('org_id', id);
|
|
204
|
-
if (cdErr) throw
|
|
210
|
+
if (cdErr) throw mapPostgrestError(cdErr);
|
|
205
211
|
|
|
206
212
|
const { error: shErr } = await client.from('compute_server_shares').delete().eq('org_id', id);
|
|
207
|
-
if (shErr) throw
|
|
213
|
+
if (shErr) throw mapPostgrestError(shErr);
|
|
208
214
|
|
|
209
215
|
const { error: csErr } = await client
|
|
210
216
|
.from('compute_servers')
|
|
211
217
|
.delete()
|
|
212
218
|
.eq('scope', 'org')
|
|
213
219
|
.eq('owner_org_id', id);
|
|
214
|
-
if (csErr) throw
|
|
220
|
+
if (csErr) throw mapPostgrestError(csErr);
|
|
215
221
|
|
|
216
222
|
await this.events.emit({ type: 'org.deleted', orgId: id, actorId: actorFrom(ctx) });
|
|
217
223
|
}
|
|
@@ -228,12 +234,12 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
228
234
|
const { data, error, count } = await this.clients
|
|
229
235
|
.forRequest(ctx)
|
|
230
236
|
.from('org_members')
|
|
231
|
-
.select(
|
|
237
|
+
.select(ORG_MEMBER_COLUMNS, { count: 'exact' })
|
|
232
238
|
.eq('org_id', orgId)
|
|
233
239
|
.is('deleted_at', null)
|
|
234
240
|
.order('joined_at', { ascending: (opts?.orderDir ?? 'desc') === 'asc' })
|
|
235
241
|
.range(range.from, range.to);
|
|
236
|
-
if (error) throw
|
|
242
|
+
if (error) throw mapPostgrestError(error);
|
|
237
243
|
const items = (data ?? []).map(rowToOrgMember);
|
|
238
244
|
return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
|
|
239
245
|
}
|
|
@@ -246,12 +252,12 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
246
252
|
const { data, error } = await this.clients
|
|
247
253
|
.forRequest(ctx)
|
|
248
254
|
.from('org_members')
|
|
249
|
-
.select(
|
|
255
|
+
.select(ORG_MEMBER_COLUMNS)
|
|
250
256
|
.eq('org_id', orgId)
|
|
251
257
|
.eq('user_id', userId)
|
|
252
258
|
.is('deleted_at', null)
|
|
253
259
|
.maybeSingle();
|
|
254
|
-
if (error) throw
|
|
260
|
+
if (error) throw mapPostgrestError(error);
|
|
255
261
|
return data ? rowToOrgMember(data) : null;
|
|
256
262
|
}
|
|
257
263
|
|
|
@@ -266,14 +272,14 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
266
272
|
const { data, error } = await this.clients
|
|
267
273
|
.forRequest(ctx)
|
|
268
274
|
.from('org_members')
|
|
269
|
-
.select(
|
|
275
|
+
.select(`${ORG_MEMBER_COLUMNS}, org:orgs!inner(${ORG_COLUMNS})`)
|
|
270
276
|
.eq('user_id', userId)
|
|
271
277
|
.is('deleted_at', null)
|
|
272
278
|
.is('org.deleted_at', null)
|
|
273
279
|
.order('joined_at', { ascending: true })
|
|
274
280
|
.limit(1)
|
|
275
281
|
.maybeSingle();
|
|
276
|
-
if (error) throw
|
|
282
|
+
if (error) throw mapPostgrestError(error);
|
|
277
283
|
if (!data) return null;
|
|
278
284
|
|
|
279
285
|
// PostgREST embeds the related row as `org` per the alias above. Strip
|
|
@@ -293,14 +299,14 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
293
299
|
// a duplicate-key error. Mirrors LocalOrgStore.addOrgMember.
|
|
294
300
|
const row: Record<string, unknown> = {
|
|
295
301
|
...memberToRow(member),
|
|
296
|
-
deleted_at: null
|
|
302
|
+
deleted_at: null,
|
|
303
|
+
...stampUpdate(ctx)
|
|
297
304
|
};
|
|
298
|
-
if (ctx.userId) row.updated_by = ctx.userId;
|
|
299
305
|
const { error } = await this.clients
|
|
300
306
|
.forRequest(ctx)
|
|
301
307
|
.from('org_members')
|
|
302
308
|
.upsert(row, { onConflict: 'org_id,user_id' });
|
|
303
|
-
if (error) throw
|
|
309
|
+
if (error) throw mapPostgrestError(error);
|
|
304
310
|
await this.events.emit({
|
|
305
311
|
type: 'org_member.added',
|
|
306
312
|
orgId: member.orgId,
|
|
@@ -318,9 +324,9 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
318
324
|
// Role change re-seeds default permissions. Matches LocalOrgStore.
|
|
319
325
|
const row: Record<string, unknown> = {
|
|
320
326
|
role,
|
|
321
|
-
permissions: [...DEFAULT_ORG_PERMISSIONS[role]]
|
|
327
|
+
permissions: [...DEFAULT_ORG_PERMISSIONS[role]],
|
|
328
|
+
...stampUpdate(ctx)
|
|
322
329
|
};
|
|
323
|
-
if (ctx.userId) row.updated_by = ctx.userId;
|
|
324
330
|
const { data, error } = await this.clients
|
|
325
331
|
.forRequest(ctx)
|
|
326
332
|
.from('org_members')
|
|
@@ -329,7 +335,7 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
329
335
|
.eq('user_id', userId)
|
|
330
336
|
.is('deleted_at', null)
|
|
331
337
|
.select('user_id');
|
|
332
|
-
if (error) throw
|
|
338
|
+
if (error) throw mapPostgrestError(error);
|
|
333
339
|
if (!data || data.length === 0)
|
|
334
340
|
throw new ProviderError(`Org member '${userId}' not found`, 404);
|
|
335
341
|
await this.events.emit({
|
|
@@ -350,8 +356,7 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
350
356
|
// Replace permissions only. Matches LocalOrgStore.updateOrgMemberPermissions —
|
|
351
357
|
// distinct from role change so callers can grant a finer-grained set without
|
|
352
358
|
// re-seeding defaults from the role.
|
|
353
|
-
const row: Record<string, unknown> = { permissions: [...permissions] };
|
|
354
|
-
if (ctx.userId) row.updated_by = ctx.userId;
|
|
359
|
+
const row: Record<string, unknown> = { permissions: [...permissions], ...stampUpdate(ctx) };
|
|
355
360
|
const { data, error } = await this.clients
|
|
356
361
|
.forRequest(ctx)
|
|
357
362
|
.from('org_members')
|
|
@@ -360,7 +365,7 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
360
365
|
.eq('user_id', userId)
|
|
361
366
|
.is('deleted_at', null)
|
|
362
367
|
.select('user_id');
|
|
363
|
-
if (error) throw
|
|
368
|
+
if (error) throw mapPostgrestError(error);
|
|
364
369
|
if (!data || data.length === 0)
|
|
365
370
|
throw new ProviderError(`Org member '${userId}' not found`, 404);
|
|
366
371
|
await this.events.emit({
|
|
@@ -379,15 +384,14 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
379
384
|
// cascade BEFORE the org_member update so RLS policies that gate
|
|
380
385
|
// project_members on org_member existence still pass for the duration.
|
|
381
386
|
const client = this.clients.forRequest(ctx);
|
|
382
|
-
const
|
|
383
|
-
const stampRow = stampToRow(stamp);
|
|
387
|
+
const stampRow = stampSoftDelete(ctx);
|
|
384
388
|
|
|
385
389
|
const { data: orgProjects, error: projError } = await client
|
|
386
390
|
.from('projects')
|
|
387
391
|
.select('id')
|
|
388
392
|
.eq('org_id', orgId)
|
|
389
393
|
.is('deleted_at', null);
|
|
390
|
-
if (projError) throw
|
|
394
|
+
if (projError) throw mapPostgrestError(projError);
|
|
391
395
|
|
|
392
396
|
const projectIds = (orgProjects ?? []).map((p) => p.id as string);
|
|
393
397
|
if (projectIds.length > 0) {
|
|
@@ -397,7 +401,7 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
397
401
|
.in('project_id', projectIds)
|
|
398
402
|
.eq('user_id', userId)
|
|
399
403
|
.is('deleted_at', null);
|
|
400
|
-
if (pmError) throw
|
|
404
|
+
if (pmError) throw mapPostgrestError(pmError);
|
|
401
405
|
}
|
|
402
406
|
|
|
403
407
|
const { error } = await client
|
|
@@ -406,7 +410,7 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
406
410
|
.eq('org_id', orgId)
|
|
407
411
|
.eq('user_id', userId)
|
|
408
412
|
.is('deleted_at', null);
|
|
409
|
-
if (error) throw
|
|
413
|
+
if (error) throw mapPostgrestError(error);
|
|
410
414
|
await this.events.emit({
|
|
411
415
|
type: 'org_member.removed',
|
|
412
416
|
orgId,
|
|
@@ -416,26 +420,6 @@ export class SupabaseOrgStore implements IOrgStore {
|
|
|
416
420
|
}
|
|
417
421
|
}
|
|
418
422
|
|
|
419
|
-
/**
|
|
420
|
-
* Build a row payload from an audit-soft-delete stamp. `updated_by` only
|
|
421
|
-
* goes into the row when ctx.userId is set — passing `''` would violate the
|
|
422
|
-
* `references auth.users(id)` FK on system contexts. The DB trigger sets
|
|
423
|
-
* `updated_at`, but for soft-delete we set it explicitly so it matches
|
|
424
|
-
* `deleted_at` (single timestamp for the deletion event).
|
|
425
|
-
*/
|
|
426
|
-
function stampToRow(stamp: {
|
|
427
|
-
updatedAt: string;
|
|
428
|
-
updatedBy: string;
|
|
429
|
-
deletedAt: string;
|
|
430
|
-
}): Record<string, unknown> {
|
|
431
|
-
const row: Record<string, unknown> = {
|
|
432
|
-
deleted_at: stamp.deletedAt,
|
|
433
|
-
updated_at: stamp.updatedAt
|
|
434
|
-
};
|
|
435
|
-
if (stamp.updatedBy) row.updated_by = stamp.updatedBy;
|
|
436
|
-
return row;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
423
|
// ============================================================================
|
|
440
424
|
// Row ↔ domain mappers
|
|
441
425
|
// ============================================================================
|
|
@@ -522,31 +506,3 @@ function memberToRow(m: OrgMember): OrgMemberRow {
|
|
|
522
506
|
joined_at: m.joinedAt
|
|
523
507
|
};
|
|
524
508
|
}
|
|
525
|
-
|
|
526
|
-
// ============================================================================
|
|
527
|
-
// Error translation
|
|
528
|
-
// ============================================================================
|
|
529
|
-
interface PostgrestError {
|
|
530
|
-
code?: string;
|
|
531
|
-
message?: string;
|
|
532
|
-
details?: string;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function mapError(e: unknown): Error {
|
|
536
|
-
const pg = e as PostgrestError;
|
|
537
|
-
if (pg?.code === '23505') {
|
|
538
|
-
return new ProviderError(pg.message ?? 'Duplicate record', 409);
|
|
539
|
-
}
|
|
540
|
-
if (pg?.code === '23503') {
|
|
541
|
-
return new ProviderError(pg.message ?? 'Foreign key violation', 409);
|
|
542
|
-
}
|
|
543
|
-
if (e instanceof Error) return e;
|
|
544
|
-
if (e && typeof e === 'object') {
|
|
545
|
-
const obj = e as { message?: string; details?: string; hint?: string; code?: string };
|
|
546
|
-
const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
|
|
547
|
-
const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
|
|
548
|
-
Object.assign(err, obj);
|
|
549
|
-
return err;
|
|
550
|
-
}
|
|
551
|
-
return new Error(String(e));
|
|
552
|
-
}
|
|
@@ -8,9 +8,18 @@ import type {
|
|
|
8
8
|
ListOptions,
|
|
9
9
|
Page
|
|
10
10
|
} from '@selvajs/platform';
|
|
11
|
-
import { ProviderError,
|
|
11
|
+
import { ProviderError, actorFrom, NoopEventSink } from '@selvajs/platform';
|
|
12
12
|
import type { ClientBundle } from './client.js';
|
|
13
|
+
import { mapPostgrestError } from './errors.js';
|
|
13
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';
|
|
14
23
|
|
|
15
24
|
/**
|
|
16
25
|
* Project + project-membership store backed by Postgres. Visibility semantics
|
|
@@ -42,12 +51,12 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
42
51
|
const { data, error, count } = await this.clients
|
|
43
52
|
.forRequest(ctx)
|
|
44
53
|
.from('projects')
|
|
45
|
-
.select(
|
|
54
|
+
.select(PROJECT_COLUMNS, { count: 'exact' })
|
|
46
55
|
.eq('org_id', orgId)
|
|
47
56
|
.is('deleted_at', null)
|
|
48
57
|
.order(orderColumn(opts?.orderBy), { ascending: direction === 'asc' })
|
|
49
58
|
.range(range.from, range.to);
|
|
50
|
-
if (error) throw
|
|
59
|
+
if (error) throw mapPostgrestError(error);
|
|
51
60
|
const items = (data ?? []).map(rowToProject);
|
|
52
61
|
return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
|
|
53
62
|
}
|
|
@@ -56,11 +65,11 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
56
65
|
const { data, error } = await this.clients
|
|
57
66
|
.forRequest(ctx)
|
|
58
67
|
.from('projects')
|
|
59
|
-
.select(
|
|
68
|
+
.select(PROJECT_COLUMNS)
|
|
60
69
|
.eq('id', id)
|
|
61
70
|
.is('deleted_at', null)
|
|
62
71
|
.maybeSingle();
|
|
63
|
-
if (error) throw
|
|
72
|
+
if (error) throw mapPostgrestError(error);
|
|
64
73
|
return data ? rowToProject(data) : null;
|
|
65
74
|
}
|
|
66
75
|
|
|
@@ -72,19 +81,19 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
72
81
|
const { data, error } = await this.clients
|
|
73
82
|
.forRequest(ctx)
|
|
74
83
|
.from('projects')
|
|
75
|
-
.select(
|
|
84
|
+
.select(PROJECT_COLUMNS)
|
|
76
85
|
.eq('org_id', orgId)
|
|
77
86
|
.eq('slug', slug)
|
|
78
87
|
.is('deleted_at', null)
|
|
79
88
|
.maybeSingle();
|
|
80
|
-
if (error) throw
|
|
89
|
+
if (error) throw mapPostgrestError(error);
|
|
81
90
|
return data ? rowToProject(data) : null;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
async createProject(ctx: RequestContext, project: Project): Promise<void> {
|
|
85
94
|
const client = this.clients.forRequest(ctx);
|
|
86
95
|
const { error } = await client.from('projects').insert(projectToRow(project));
|
|
87
|
-
if (error) throw
|
|
96
|
+
if (error) throw mapPostgrestError(error);
|
|
88
97
|
|
|
89
98
|
// Seed owner membership so user-scoped reads see the project post-create.
|
|
90
99
|
// Upsert so a stale soft-deleted row from a prior project with the same id
|
|
@@ -99,7 +108,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
99
108
|
},
|
|
100
109
|
{ onConflict: 'project_id,user_id' }
|
|
101
110
|
);
|
|
102
|
-
if (memberError) throw
|
|
111
|
+
if (memberError) throw mapPostgrestError(memberError);
|
|
103
112
|
await this.events.emit({
|
|
104
113
|
type: 'project.created',
|
|
105
114
|
projectId: project.id,
|
|
@@ -122,7 +131,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
122
131
|
if (patch.visibility !== undefined) row.visibility = patch.visibility;
|
|
123
132
|
if (patch.autoJoinOnUpload !== undefined) row.auto_join_on_upload = patch.autoJoinOnUpload;
|
|
124
133
|
if (Object.keys(row).length === 0) return;
|
|
125
|
-
|
|
134
|
+
Object.assign(row, stampUpdate(ctx));
|
|
126
135
|
|
|
127
136
|
const { data, error } = await this.clients
|
|
128
137
|
.forRequest(ctx)
|
|
@@ -131,7 +140,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
131
140
|
.eq('id', id)
|
|
132
141
|
.is('deleted_at', null)
|
|
133
142
|
.select('id');
|
|
134
|
-
if (error) throw
|
|
143
|
+
if (error) throw mapPostgrestError(error);
|
|
135
144
|
if (!data || data.length === 0) throw new ProviderError(`Project '${id}' not found`, 404);
|
|
136
145
|
}
|
|
137
146
|
|
|
@@ -140,8 +149,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
140
149
|
// project_members, definitions. FK CASCADE doesn't fire on soft-delete,
|
|
141
150
|
// so the cascade is in app code.
|
|
142
151
|
const client = this.clients.forRequest(ctx);
|
|
143
|
-
const
|
|
144
|
-
const stampRow = stampToRow(stamp);
|
|
152
|
+
const stampRow = stampSoftDelete(ctx);
|
|
145
153
|
|
|
146
154
|
const { data, error } = await client
|
|
147
155
|
.from('projects')
|
|
@@ -149,7 +157,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
149
157
|
.eq('id', id)
|
|
150
158
|
.is('deleted_at', null)
|
|
151
159
|
.select('id');
|
|
152
|
-
if (error) throw
|
|
160
|
+
if (error) throw mapPostgrestError(error);
|
|
153
161
|
if (!data || data.length === 0) throw new ProviderError(`Project '${id}' not found`, 404);
|
|
154
162
|
|
|
155
163
|
const { error: pmErr } = await client
|
|
@@ -157,14 +165,28 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
157
165
|
.update(stampRow)
|
|
158
166
|
.eq('project_id', id)
|
|
159
167
|
.is('deleted_at', null);
|
|
160
|
-
if (pmErr) throw
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
163
177
|
.from('definitions')
|
|
164
178
|
.update(stampRow)
|
|
165
179
|
.eq('project_id', id)
|
|
166
|
-
.is('deleted_at', null)
|
|
167
|
-
|
|
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
|
+
}
|
|
168
190
|
|
|
169
191
|
await this.events.emit({ type: 'project.deleted', projectId: id, actorId: actorFrom(ctx) });
|
|
170
192
|
}
|
|
@@ -185,9 +207,9 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
185
207
|
.eq('org_id', orgId)
|
|
186
208
|
.eq('slug', slug)
|
|
187
209
|
.not('deleted_at', 'is', null)
|
|
188
|
-
.select(
|
|
210
|
+
.select(PROJECT_COLUMNS)
|
|
189
211
|
.maybeSingle();
|
|
190
|
-
if (error) throw
|
|
212
|
+
if (error) throw mapPostgrestError(error);
|
|
191
213
|
if (!data) return null;
|
|
192
214
|
|
|
193
215
|
// Reactivate the owner's project_members row (deleteProject cascades to it).
|
|
@@ -197,7 +219,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
197
219
|
.eq('project_id', data.id)
|
|
198
220
|
.eq('user_id', data.owner_id)
|
|
199
221
|
.not('deleted_at', 'is', null);
|
|
200
|
-
if (pmErr) throw
|
|
222
|
+
if (pmErr) throw mapPostgrestError(pmErr);
|
|
201
223
|
|
|
202
224
|
const project = rowToProject(data);
|
|
203
225
|
await this.events.emit({
|
|
@@ -221,12 +243,12 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
221
243
|
const { data, error, count } = await this.clients
|
|
222
244
|
.forRequest(ctx)
|
|
223
245
|
.from('project_members')
|
|
224
|
-
.select(
|
|
246
|
+
.select(PROJECT_MEMBER_COLUMNS, { count: 'exact' })
|
|
225
247
|
.eq('project_id', projectId)
|
|
226
248
|
.is('deleted_at', null)
|
|
227
249
|
.order('joined_at', { ascending: (opts?.orderDir ?? 'desc') === 'asc' })
|
|
228
250
|
.range(range.from, range.to);
|
|
229
|
-
if (error) throw
|
|
251
|
+
if (error) throw mapPostgrestError(error);
|
|
230
252
|
const items = (data ?? []).map(rowToProjectMember);
|
|
231
253
|
return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
|
|
232
254
|
}
|
|
@@ -239,12 +261,12 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
239
261
|
const { data, error } = await this.clients
|
|
240
262
|
.forRequest(ctx)
|
|
241
263
|
.from('project_members')
|
|
242
|
-
.select(
|
|
264
|
+
.select(PROJECT_MEMBER_COLUMNS)
|
|
243
265
|
.eq('project_id', projectId)
|
|
244
266
|
.eq('user_id', userId)
|
|
245
267
|
.is('deleted_at', null)
|
|
246
268
|
.maybeSingle();
|
|
247
|
-
if (error) throw
|
|
269
|
+
if (error) throw mapPostgrestError(error);
|
|
248
270
|
return data ? rowToProjectMember(data) : null;
|
|
249
271
|
}
|
|
250
272
|
|
|
@@ -253,14 +275,14 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
253
275
|
// duplicate-key. Mirrors LocalProjectStore.addProjectMember.
|
|
254
276
|
const row: Record<string, unknown> = {
|
|
255
277
|
...projectMemberToRow(member),
|
|
256
|
-
deleted_at: null
|
|
278
|
+
deleted_at: null,
|
|
279
|
+
...stampUpdate(ctx)
|
|
257
280
|
};
|
|
258
|
-
if (ctx.userId) row.updated_by = ctx.userId;
|
|
259
281
|
const { error } = await this.clients
|
|
260
282
|
.forRequest(ctx)
|
|
261
283
|
.from('project_members')
|
|
262
284
|
.upsert(row, { onConflict: 'project_id,user_id' });
|
|
263
|
-
if (error) throw
|
|
285
|
+
if (error) throw mapPostgrestError(error);
|
|
264
286
|
await this.events.emit({
|
|
265
287
|
type: 'project_member.added',
|
|
266
288
|
projectId: member.projectId,
|
|
@@ -275,8 +297,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
275
297
|
userId: string,
|
|
276
298
|
role: ProjectRole
|
|
277
299
|
): Promise<void> {
|
|
278
|
-
const row: Record<string, unknown> = { role };
|
|
279
|
-
if (ctx.userId) row.updated_by = ctx.userId;
|
|
300
|
+
const row: Record<string, unknown> = { role, ...stampUpdate(ctx) };
|
|
280
301
|
const { data, error } = await this.clients
|
|
281
302
|
.forRequest(ctx)
|
|
282
303
|
.from('project_members')
|
|
@@ -285,7 +306,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
285
306
|
.eq('user_id', userId)
|
|
286
307
|
.is('deleted_at', null)
|
|
287
308
|
.select('user_id');
|
|
288
|
-
if (error) throw
|
|
309
|
+
if (error) throw mapPostgrestError(error);
|
|
289
310
|
if (!data || data.length === 0)
|
|
290
311
|
throw new ProviderError(`Project member '${userId}' not found`, 404);
|
|
291
312
|
await this.events.emit({
|
|
@@ -298,15 +319,14 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
298
319
|
}
|
|
299
320
|
|
|
300
321
|
async removeProjectMember(ctx: RequestContext, projectId: string, userId: string): Promise<void> {
|
|
301
|
-
const stamp = auditSoftDelete(ctx, ctx.userId);
|
|
302
322
|
const { error } = await this.clients
|
|
303
323
|
.forRequest(ctx)
|
|
304
324
|
.from('project_members')
|
|
305
|
-
.update(
|
|
325
|
+
.update(stampSoftDelete(ctx))
|
|
306
326
|
.eq('project_id', projectId)
|
|
307
327
|
.eq('user_id', userId)
|
|
308
328
|
.is('deleted_at', null);
|
|
309
|
-
if (error) throw
|
|
329
|
+
if (error) throw mapPostgrestError(error);
|
|
310
330
|
await this.events.emit({
|
|
311
331
|
type: 'project_member.removed',
|
|
312
332
|
projectId,
|
|
@@ -409,46 +429,3 @@ function projectMemberToRow(m: ProjectMember): ProjectMemberRow {
|
|
|
409
429
|
joined_at: m.joinedAt
|
|
410
430
|
};
|
|
411
431
|
}
|
|
412
|
-
|
|
413
|
-
// ============================================================================
|
|
414
|
-
// Error translation
|
|
415
|
-
// ============================================================================
|
|
416
|
-
interface PostgrestError {
|
|
417
|
-
code?: string;
|
|
418
|
-
message?: string;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Build a row payload from an audit-soft-delete stamp. `updated_by` only goes
|
|
423
|
-
* into the row when ctx.userId is set — passing `''` would violate the
|
|
424
|
-
* `references auth.users(id)` FK on system contexts. The DB trigger sets
|
|
425
|
-
* `updated_at`, but for soft-delete we set it explicitly so it matches
|
|
426
|
-
* `deleted_at` (single timestamp for the deletion event).
|
|
427
|
-
*/
|
|
428
|
-
function stampToRow(stamp: {
|
|
429
|
-
updatedAt: string;
|
|
430
|
-
updatedBy: string;
|
|
431
|
-
deletedAt: string;
|
|
432
|
-
}): Record<string, unknown> {
|
|
433
|
-
const row: Record<string, unknown> = {
|
|
434
|
-
deleted_at: stamp.deletedAt,
|
|
435
|
-
updated_at: stamp.updatedAt
|
|
436
|
-
};
|
|
437
|
-
if (stamp.updatedBy) row.updated_by = stamp.updatedBy;
|
|
438
|
-
return row;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function mapError(e: unknown): Error {
|
|
442
|
-
const pg = e as PostgrestError;
|
|
443
|
-
if (pg?.code === '23505') return new ProviderError(pg.message ?? 'Duplicate record', 409);
|
|
444
|
-
if (pg?.code === '23503') return new ProviderError(pg.message ?? 'Foreign key violation', 409);
|
|
445
|
-
if (e instanceof Error) return e;
|
|
446
|
-
if (e && typeof e === 'object') {
|
|
447
|
-
const obj = e as { message?: string; details?: string; hint?: string; code?: string };
|
|
448
|
-
const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
|
|
449
|
-
const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
|
|
450
|
-
Object.assign(err, obj);
|
|
451
|
-
return err;
|
|
452
|
-
}
|
|
453
|
-
return new Error(String(e));
|
|
454
|
-
}
|