Skip to content

Commit ac8ca1f

Browse files
authored
Merge pull request #1590 from dcoric/feat/postgres-repo-users
feat: normalise PostgreSQL repo permissions into a repo_users join table
2 parents 13caea1 + 5fe3ad9 commit ac8ca1f

10 files changed

Lines changed: 527 additions & 164 deletions

File tree

src/db/postgres/helper.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { Pool, PoolConfig, QueryResult, QueryResultRow } from 'pg';
17+
import { Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow } from 'pg';
1818
import session, { Store } from 'express-session';
1919
import connectPgSimple from 'connect-pg-simple';
2020

@@ -127,6 +127,31 @@ export const connect = async (): Promise<Pool> => {
127127
return pool;
128128
};
129129

130+
/**
131+
* Run `fn` inside a single transaction: every statement issued through the
132+
* supplied client commits or rolls back together. Used where one logical
133+
* update spans several statements, so a failure cannot leave partial state.
134+
*/
135+
export const withTransaction = async <T>(fn: (client: PoolClient) => Promise<T>): Promise<T> => {
136+
const pool = await connect();
137+
const client = await pool.connect();
138+
try {
139+
await client.query('BEGIN');
140+
const result = await fn(client);
141+
await client.query('COMMIT');
142+
return result;
143+
} catch (err) {
144+
try {
145+
await client.query('ROLLBACK');
146+
} catch {
147+
// the original error is the one worth surfacing
148+
}
149+
throw err;
150+
} finally {
151+
client.release();
152+
}
153+
};
154+
130155
export const query = async <T extends QueryResultRow = QueryResultRow>(
131156
text: string,
132157
params?: ReadonlyArray<unknown>,

src/db/postgres/repo.ts

Lines changed: 116 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
*/
1616

1717
import { Repo, RepoQuery } from '../types';
18-
import { query } from './helper';
18+
import { query, withTransaction } from './helper';
1919

2020
interface 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

4764
export 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

6885
export 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

7592
export 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

8097
export 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+
85135
export 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
*/
113169
export 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

214235
export 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

226247
export 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
};

src/db/postgres/schemaMigrations.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,44 @@ export const MIGRATIONS: Migration[] = [
107107
);
108108
`,
109109
},
110+
{
111+
version: 4,
112+
name: 'repo_users_table',
113+
sql: `
114+
CREATE TABLE IF NOT EXISTS repo_users (
115+
repo_id UUID NOT NULL REFERENCES repos(_id) ON DELETE CASCADE,
116+
username TEXT NOT NULL,
117+
role TEXT NOT NULL CHECK (role IN ('canPush', 'canAuthorise')),
118+
PRIMARY KEY (repo_id, username, role)
119+
);
120+
CREATE INDEX IF NOT EXISTS repo_users_repo_id_idx ON repo_users (repo_id);
121+
122+
-- Backfill the normalised table from the existing JSONB permissions. The
123+
-- legacy repos.users column is dropped in a later migration once the adapter
124+
-- reads and writes repo_users instead.
125+
-- Usernames are lowercased to match the runtime writers (addUserToRole and
126+
-- friends lowercase on insert), so legacy mixed-case entries stay
127+
-- retrievable; ON CONFLICT collapses any case-only duplicates.
128+
INSERT INTO repo_users (repo_id, username, role)
129+
SELECT r._id, lower(elem.username), 'canPush'
130+
FROM repos r,
131+
jsonb_array_elements_text(coalesce(r.users->'canPush', '[]'::jsonb)) AS elem(username)
132+
ON CONFLICT DO NOTHING;
133+
134+
INSERT INTO repo_users (repo_id, username, role)
135+
SELECT r._id, lower(elem.username), 'canAuthorise'
136+
FROM repos r,
137+
jsonb_array_elements_text(coalesce(r.users->'canAuthorise', '[]'::jsonb)) AS elem(username)
138+
ON CONFLICT DO NOTHING;
139+
`,
140+
},
141+
{
142+
version: 5,
143+
name: 'drop_repos_users_jsonb',
144+
// The repo adapter now reads and writes permissions via repo_users, so the
145+
// legacy JSONB column (backfilled in migration 4) is no longer used.
146+
sql: `ALTER TABLE repos DROP COLUMN IF EXISTS users;`,
147+
},
110148
];
111149

112150
const SCHEMA_MIGRATIONS_TABLE_SQL = `

0 commit comments

Comments
 (0)