1515 */
1616
1717import { Repo , RepoQuery } from '../types' ;
18- import { query } from './helper' ;
18+ import { query , withTransaction } from './helper' ;
1919
2020interface RepoRow {
2121 _id : string ;
2222 project : string ;
2323 name : string ;
2424 url : string ;
25- users : { canPush : string [ ] ; canAuthorise : string [ ] } | null ;
25+ can_push : string [ ] | null ;
26+ can_authorise : string [ ] | null ;
2627 date_created : string | null ;
2728 last_modified : string | null ;
2829}
@@ -32,86 +33,141 @@ const rowToRepo = (row: RepoRow): Repo =>
3233 row . project ,
3334 row . name ,
3435 row . url ,
35- // Guard against null/legacy rows so callers always see arrays.
3636 {
37- canPush : row . users ?. canPush ?? [ ] ,
38- canAuthorise : row . users ?. canAuthorise ?? [ ] ,
37+ canPush : row . can_push ?? [ ] ,
38+ canAuthorise : row . can_authorise ?? [ ] ,
3939 } ,
4040 row . _id ,
4141 row . date_created ?? undefined ,
4242 row . last_modified ?? undefined ,
4343 ) ;
4444
45- const SELECT_COLUMNS = '_id, project, name, url, users, date_created, last_modified' ;
45+ // Reconstruct the `canPush` / `canAuthorise` arrays from the normalised
46+ // repo_users join table. `ORDER BY` keeps the arrays deterministic, and the
47+ // `coalesce(..., '{}')` makes a repo with no members come back as empty arrays
48+ // rather than null, matching the mongo/NeDB backends.
49+ const SELECT_REPOS = `
50+ SELECT r._id, r.project, r.name, r.url, r.date_created, r.last_modified,
51+ coalesce(
52+ array_agg(ru.username ORDER BY ru.username) FILTER (WHERE ru.role = 'canPush'),
53+ '{}'
54+ ) AS can_push,
55+ coalesce(
56+ array_agg(ru.username ORDER BY ru.username) FILTER (WHERE ru.role = 'canAuthorise'),
57+ '{}'
58+ ) AS can_authorise
59+ FROM repos r
60+ LEFT JOIN repo_users ru ON ru.repo_id = r._id` ;
61+
62+ const GROUP_BY = 'GROUP BY r._id' ;
4663
4764export const getRepos = async ( q : Partial < RepoQuery > = { } ) : Promise < Repo [ ] > => {
4865 const clauses : string [ ] = [ ] ;
4966 const values : unknown [ ] = [ ] ;
5067 if ( q . name ) {
5168 values . push ( q . name . toLowerCase ( ) ) ;
52- clauses . push ( `name = $${ values . length } ` ) ;
69+ clauses . push ( `r. name = $${ values . length } ` ) ;
5370 }
5471 if ( q . project !== undefined ) {
5572 values . push ( q . project ) ;
56- clauses . push ( `project = $${ values . length } ` ) ;
73+ clauses . push ( `r. project = $${ values . length } ` ) ;
5774 }
5875 if ( q . url ) {
5976 values . push ( q . url ) ;
60- clauses . push ( `url = $${ values . length } ` ) ;
77+ clauses . push ( `r. url = $${ values . length } ` ) ;
6178 }
6279
6380 const where = clauses . length > 0 ? `WHERE ${ clauses . join ( ' AND ' ) } ` : '' ;
64- const result = await query < RepoRow > ( `SELECT ${ SELECT_COLUMNS } FROM repos ${ where } ` , values ) ;
81+ const result = await query < RepoRow > ( `${ SELECT_REPOS } ${ where } ${ GROUP_BY } ` , values ) ;
6582 return result . rows . map ( rowToRepo ) ;
6683} ;
6784
6885export const getRepo = async ( name : string ) : Promise < Repo | null > => {
69- const result = await query < RepoRow > ( `SELECT ${ SELECT_COLUMNS } FROM repos WHERE name = $1` , [
86+ const result = await query < RepoRow > ( `${ SELECT_REPOS } WHERE r. name = $1 ${ GROUP_BY } ` , [
7087 name . toLowerCase ( ) ,
7188 ] ) ;
7289 return result . rowCount === 0 ? null : rowToRepo ( result . rows [ 0 ] ) ;
7390} ;
7491
7592export const getRepoByUrl = async ( url : string ) : Promise < Repo | null > => {
76- const result = await query < RepoRow > ( `SELECT ${ SELECT_COLUMNS } FROM repos WHERE url = $1` , [ url ] ) ;
93+ const result = await query < RepoRow > ( `${ SELECT_REPOS } WHERE r. url = $1 ${ GROUP_BY } ` , [ url ] ) ;
7794 return result . rowCount === 0 ? null : rowToRepo ( result . rows [ 0 ] ) ;
7895} ;
7996
8097export const getRepoById = async ( _id : string ) : Promise < Repo | null > => {
81- const result = await query < RepoRow > ( `SELECT ${ SELECT_COLUMNS } FROM repos WHERE _id = $1` , [ _id ] ) ;
98+ const result = await query < RepoRow > ( `${ SELECT_REPOS } WHERE r. _id = $1 ${ GROUP_BY } ` , [ _id ] ) ;
8299 return result . rowCount === 0 ? null : rowToRepo ( result . rows [ 0 ] ) ;
83100} ;
84101
102+ const addUserToRole = async (
103+ _id : string ,
104+ user : string ,
105+ role : 'canPush' | 'canAuthorise' ,
106+ ) : Promise < void > => {
107+ await query (
108+ `INSERT INTO repo_users (repo_id, username, role)
109+ VALUES ($1, $2, $3)
110+ ON CONFLICT DO NOTHING` ,
111+ [ _id , user . toLowerCase ( ) , role ] ,
112+ ) ;
113+ await query ( `UPDATE repos SET last_modified = $2 WHERE _id = $1` , [
114+ _id ,
115+ new Date ( ) . toISOString ( ) ,
116+ ] ) ;
117+ } ;
118+
119+ const removeUserFromRole = async (
120+ _id : string ,
121+ user : string ,
122+ role : 'canPush' | 'canAuthorise' ,
123+ ) : Promise < void > => {
124+ await query ( `DELETE FROM repo_users WHERE repo_id = $1 AND username = $2 AND role = $3` , [
125+ _id ,
126+ user . toLowerCase ( ) ,
127+ role ,
128+ ] ) ;
129+ await query ( `UPDATE repos SET last_modified = $2 WHERE _id = $1` , [
130+ _id ,
131+ new Date ( ) . toISOString ( ) ,
132+ ] ) ;
133+ } ;
134+
85135export const createRepo = async ( repo : Repo ) : Promise < Repo > => {
86136 const users = repo . users ?? { canPush : [ ] , canAuthorise : [ ] } ;
87137 const now = new Date ( ) . toISOString ( ) ;
88138 if ( ! repo . dateCreated ) repo . dateCreated = now ;
89139 if ( ! repo . lastModified ) repo . lastModified = now ;
90140 const result = await query < { _id : string } > (
91- `INSERT INTO repos (project, name, url, users, date_created, last_modified)
92- VALUES ($1, $2, $3, $4::jsonb , $5, $6 )
141+ `INSERT INTO repos (project, name, url, date_created, last_modified)
142+ VALUES ($1, $2, $3, $4, $5)
93143 RETURNING _id` ,
94- [
95- repo . project ?? '' ,
96- repo . name ,
97- repo . url ,
98- JSON . stringify ( users ) ,
99- repo . dateCreated ,
100- repo . lastModified ,
101- ] ,
144+ [ repo . project ?? '' , repo . name , repo . url , repo . dateCreated , repo . lastModified ] ,
102145 ) ;
103- repo . _id = result . rows [ 0 ] . _id ;
146+ const _id = result . rows [ 0 ] . _id ;
147+
148+ // Persist any permissions supplied at creation into the join table.
149+ for ( const username of users . canPush ?? [ ] ) {
150+ await addUserToRole ( _id , username , 'canPush' ) ;
151+ }
152+ for ( const username of users . canAuthorise ?? [ ] ) {
153+ await addUserToRole ( _id , username , 'canAuthorise' ) ;
154+ }
155+
156+ repo . _id = _id ;
104157 repo . users = users ;
105158 return repo ;
106159} ;
107160
108161/**
109162 * Apply a partial update to a repo row. Only the supplied fields are written,
110163 * matching mongo's `$set` / `$unset` behaviour: a field explicitly set to
111- * `undefined` is reset to the column default rather than left untouched.
164+ * `undefined` is reset to the column default.
165+ *
166+ * Permissions live in the `repo_users` join table rather than a column, so a
167+ * supplied `users` object replaces that repo's rows wholesale.
112168 */
113169export const updateRepo = async ( repo : Partial < Repo > ) : Promise < void > => {
114- const { _id, ...fields } = repo ;
170+ const { _id, users , ...fields } = repo ;
115171 if ( ! _id ) {
116172 throw new Error ( 'updateRepo requires a repo _id' ) ;
117173 }
@@ -120,7 +176,6 @@ export const updateRepo = async (repo: Partial<Repo>): Promise<void> => {
120176 project : 'project' ,
121177 name : 'name' ,
122178 url : 'url' ,
123- users : 'users' ,
124179 dateCreated : 'date_created' ,
125180 lastModified : 'last_modified' ,
126181 } ;
@@ -134,81 +189,47 @@ export const updateRepo = async (repo: Partial<Repo>): Promise<void> => {
134189 sets . push ( `${ column } = DEFAULT` ) ;
135190 continue ;
136191 }
137- if ( column === 'users' ) {
138- values . push ( JSON . stringify ( value ) ) ;
139- sets . push ( `${ column } = $${ values . length } ::jsonb` ) ;
140- continue ;
141- }
142192 values . push ( value ) ;
143193 sets . push ( `${ column } = $${ values . length } ` ) ;
144194 }
145195
146- if ( sets . length === 0 ) {
196+ if ( sets . length === 0 && users === undefined ) {
147197 throw new Error ( 'updateRepo requires at least one field to update' ) ;
148198 }
149199
150- values . push ( _id ) ;
151- await query ( `UPDATE repos SET ${ sets . join ( ', ' ) } WHERE _id = $${ values . length } ` , values ) ;
152- } ;
153-
154- /**
155- * Append a user to one of the JSONB permission arrays. The query is a
156- * read-modify-write that deduplicates the value, then re-serialises the array
157- * so the stored shape matches the existing mongo/fs backends exactly.
158- */
159- const addUserToRole = async (
160- _id : string ,
161- user : string ,
162- role : 'canPush' | 'canAuthorise' ,
163- ) : Promise < void > => {
164- const lowered = user . toLowerCase ( ) ;
165- await query (
166- `UPDATE repos
167- SET users = jsonb_set(
168- users,
169- $2::text[],
170- (
171- SELECT to_jsonb(
172- ARRAY(
173- SELECT DISTINCT v
174- FROM jsonb_array_elements_text(coalesce(users->$3, '[]'::jsonb)) AS v
175- UNION
176- SELECT $4
177- )
178- )
179- )
180- ),
181- last_modified = $5
182- WHERE _id = $1` ,
183- [ _id , `{${ role } }` , role , lowered , new Date ( ) . toISOString ( ) ] ,
184- ) ;
185- } ;
200+ // One transaction for the whole update: the row change, the permission
201+ // replacement and the last_modified bump land together or not at all, so a
202+ // failure partway cannot leave a repo without its roles.
203+ await withTransaction ( async ( client ) => {
204+ if ( sets . length > 0 ) {
205+ await client . query ( `UPDATE repos SET ${ sets . join ( ', ' ) } WHERE _id = $${ values . length + 1 } ` , [
206+ ...values ,
207+ _id ,
208+ ] ) ;
209+ }
186210
187- const removeUserFromRole = async (
188- _id : string ,
189- user : string ,
190- role : 'canPush' | 'canAuthorise' ,
191- ) : Promise < void > => {
192- const lowered = user . toLowerCase ( ) ;
193- // The filter evaluates to `[]` if the last matching user is removed
194- await query (
195- `UPDATE repos
196- SET users = jsonb_set(
197- users,
198- $2::text[],
199- coalesce(
200- (
201- SELECT to_jsonb(array_agg(v))
202- FROM jsonb_array_elements_text(coalesce(users->$3, '[]'::jsonb)) AS v
203- WHERE v <> $4
204- ),
205- '[]'::jsonb
206- )
207- ),
208- last_modified = $5
209- WHERE _id = $1` ,
210- [ _id , `{${ role } }` , role , lowered , new Date ( ) . toISOString ( ) ] ,
211- ) ;
211+ if ( users !== undefined ) {
212+ await client . query ( `DELETE FROM repo_users WHERE repo_id = $1` , [ _id ] ) ;
213+ const roles = [
214+ [ 'canPush' , users . canPush ?? [ ] ] ,
215+ [ 'canAuthorise' , users . canAuthorise ?? [ ] ] ,
216+ ] as const ;
217+ for ( const [ role , names ] of roles ) {
218+ for ( const username of names ) {
219+ await client . query (
220+ `INSERT INTO repo_users (repo_id, username, role)
221+ VALUES ($1, $2, $3)
222+ ON CONFLICT DO NOTHING` ,
223+ [ _id , username . toLowerCase ( ) , role ] ,
224+ ) ;
225+ }
226+ }
227+ await client . query ( `UPDATE repos SET last_modified = $2 WHERE _id = $1` , [
228+ _id ,
229+ new Date ( ) . toISOString ( ) ,
230+ ] ) ;
231+ }
232+ } ) ;
212233} ;
213234
214235export const addUserCanPush = ( _id : string , user : string ) : Promise < void > =>
@@ -224,5 +245,6 @@ export const removeUserCanAuthorise = (_id: string, user: string): Promise<void>
224245 removeUserFromRole ( _id , user , 'canAuthorise' ) ;
225246
226247export const deleteRepo = async ( _id : string ) : Promise < void > => {
248+ // repo_users rows are removed by the ON DELETE CASCADE foreign key.
227249 await query ( `DELETE FROM repos WHERE _id = $1` , [ _id ] ) ;
228250} ;
0 commit comments