Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps/api/ViGet/PostgresTazelPluginCatalog.cs
blob: 9ae20b0af391ed0a932810a53177a2bead19685e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1

using Npgsql;

namespace Progmasoft.Progmaweb.Api.ViGet;

/// <summary>The tables of <see cref="PostgresTazelPluginCatalog"/> and the steps that bring a database to them.</summary>
/// <remarks>
/// The same numbered steps as the account tables, in a version table of their own, <c>viget_schema_versions</c>.
/// A step is never edited after it has been released.
/// </remarks>
internal static class ViGetSchema
{
    /// <summary>Key of the advisory lock that lets one process at a time change these tables.</summary>
    private const long MigrationLockKey = 0x5669476574;

    /// <summary>The steps, in order. The version of a step is its position, counted from one.</summary>
    private static readonly string[] Steps =
    [
        // 1: Tazel plugins and their versions.
        //
        // A plugin is named by its publisher and its name as they are spelled; the unique index on their upper
        // case keeps two plugins from differing only in letter case. A version is never removed, only yanked.
        """
        CREATE TABLE viget_tazel_plugins (
            publisher   text        NOT NULL,
            name        text        NOT NULL,
            description text,
            authors     text[]      NOT NULL,
            license     text,
            homepage    text,
            repository  text,
            updated_at  timestamptz NOT NULL,
            PRIMARY KEY (publisher, name)
        );

        CREATE UNIQUE INDEX viget_tazel_plugins_folded_key ON viget_tazel_plugins (upper(publisher), upper(name));

        CREATE TABLE viget_tazel_plugin_versions (
            publisher    text        NOT NULL,
            name         text        NOT NULL,
            version      text        NOT NULL,
            sha256       text        NOT NULL,
            api_version  integer     NOT NULL,
            size         bigint      NOT NULL,
            published_at timestamptz NOT NULL,
            yanked       boolean     NOT NULL DEFAULT false,
            PRIMARY KEY (publisher, name, version),
            FOREIGN KEY (publisher, name) REFERENCES viget_tazel_plugins (publisher, name)
        );
        """
    ];

    /// <summary>The version a database has after every step.</summary>
    public static int CurrentVersion => Steps.Length;

    /// <summary>Takes the steps a database has not taken yet, in one transaction.</summary>
    /// <returns>The number of steps that were taken.</returns>
    /// <exception cref="InvalidOperationException">The database has taken steps this program does not know.</exception>
    public static async Task<int> ApplyAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken)
    {
        await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken);
        await using NpgsqlTransaction transaction = await connection.BeginTransactionAsync(cancellationToken);
        await ExecuteAsync(connection, transaction, $"SELECT pg_advisory_xact_lock({MigrationLockKey})", cancellationToken);
        await ExecuteAsync(
            connection,
            transaction,
            """
            CREATE TABLE IF NOT EXISTS viget_schema_versions (
                version    integer     PRIMARY KEY,
                applied_at timestamptz NOT NULL DEFAULT now()
            )
            """,
            cancellationToken);

        int version;
        await using (NpgsqlCommand read = new(
            "SELECT coalesce(max(version), 0) FROM viget_schema_versions", connection, transaction))
        {
            version = (int)(await read.ExecuteScalarAsync(cancellationToken))!;
        }

        if (version > Steps.Length)
        {
            throw new InvalidOperationException(
                $"The ViGet tables are at schema version {version}, and this program knows version {Steps.Length}.");
        }

        int taken = 0;
        for (int step = version; step < Steps.Length; step++)
        {
            await ExecuteAsync(connection, transaction, Steps[step], cancellationToken);
            await using NpgsqlCommand record = new(
                "INSERT INTO viget_schema_versions (version) VALUES ($1)", connection, transaction);
            record.Parameters.AddWithValue(step + 1);
            await record.ExecuteNonQueryAsync(cancellationToken);
            taken++;
        }

        await transaction.CommitAsync(cancellationToken);
        return taken;
    }

    /// <summary>Runs statements that return nothing.</summary>
    /// <param name="connection">The connection to run them on.</param>
    /// <param name="transaction">The transaction they belong to.</param>
    /// <param name="sql">The statements.</param>
    /// <param name="cancellationToken">Cancels the operation.</param>
    /// <returns>A task that completes when the statements have run.</returns>
    private static async Task ExecuteAsync(
        NpgsqlConnection connection,
        NpgsqlTransaction transaction,
        string sql,
        CancellationToken cancellationToken)
    {
        await using NpgsqlCommand command = new(sql, connection, transaction);
        await command.ExecuteNonQueryAsync(cancellationToken);
    }
}

/// <summary>The Tazel plugins of ViGet in PostgreSQL, beside the accounts.</summary>
/// <param name="dataSource">Connections to the database of the accounts.</param>
internal sealed class PostgresTazelPluginCatalog(NpgsqlDataSource dataSource) : ITazelPluginCatalog
{
    /// <summary>The columns of a plugin row, in the order <see cref="ReadPlugin"/> reads them.</summary>
    private const string PluginColumns = "publisher, name, description, authors, license, homepage, repository";

    /// <inheritdoc/>
    public async ValueTask<IReadOnlyList<TazelPlugin>> ListAsync(CancellationToken cancellationToken)
    {
        await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken);
        List<TazelPlugin> plugins = [];
        await using (NpgsqlCommand command = new(
            $"SELECT {PluginColumns} FROM viget_tazel_plugins ORDER BY publisher, name", connection))
        await using (NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken))
        {
            while (await reader.ReadAsync(cancellationToken))
            {
                plugins.Add(ReadPlugin(reader, []));
            }
        }

        List<TazelPlugin> withVersions = [];
        foreach (TazelPlugin plugin in plugins)
        {
            withVersions.Add(plugin with
            {
                Versions = await VersionsAsync(connection, null, plugin.Publisher, plugin.Name, cancellationToken)
            });
        }

        return withVersions;
    }

    /// <inheritdoc/>
    public async ValueTask<TazelPlugin?> FindAsync(string publisher, string name, CancellationToken cancellationToken)
    {
        await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken);
        TazelPlugin? plugin;
        await using (NpgsqlCommand command = new(
            $"SELECT {PluginColumns} FROM viget_tazel_plugins WHERE publisher = $1 AND name = $2", connection))
        {
            command.Parameters.AddWithValue(publisher);
            command.Parameters.AddWithValue(name);
            await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
            plugin = await reader.ReadAsync(cancellationToken) ? ReadPlugin(reader, []) : null;
        }

        return plugin is null
            ? null
            : plugin with { Versions = await VersionsAsync(connection, null, publisher, name, cancellationToken) };
    }

    /// <inheritdoc/>
    public async ValueTask<AddVersionResult> AddVersionAsync(
        TazelPluginDescriptor descriptor,
        string sha256,
        long size,
        DateTimeOffset publishedAt,
        CancellationToken cancellationToken)
    {
        await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken);
        await using NpgsqlTransaction transaction = await connection.BeginTransactionAsync(cancellationToken);
        await using (NpgsqlCommand taken = new(
            """
            SELECT count(*) FROM viget_tazel_plugins
            WHERE upper(publisher) = upper($1) AND upper(name) = upper($2) AND (publisher <> $1 OR name <> $2)
            """,
            connection,
            transaction))
        {
            taken.Parameters.AddWithValue(descriptor.Publisher);
            taken.Parameters.AddWithValue(descriptor.Name);
            if ((long)(await taken.ExecuteScalarAsync(cancellationToken))! > 0)
            {
                return AddVersionResult.NameTaken;
            }
        }

        await using (NpgsqlCommand plugin = new(
            $"""
            INSERT INTO viget_tazel_plugins ({PluginColumns}, updated_at)
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
            ON CONFLICT (publisher, name) DO UPDATE SET
                description = excluded.description,
                authors = excluded.authors,
                license = excluded.license,
                homepage = excluded.homepage,
                repository = excluded.repository,
                updated_at = excluded.updated_at
            """,
            connection,
            transaction))
        {
            plugin.Parameters.AddWithValue(descriptor.Publisher);
            plugin.Parameters.AddWithValue(descriptor.Name);
            plugin.Parameters.AddWithValue((object?)descriptor.Description ?? DBNull.Value);
            plugin.Parameters.AddWithValue(descriptor.Authors.ToArray());
            plugin.Parameters.AddWithValue((object?)descriptor.License ?? DBNull.Value);
            plugin.Parameters.AddWithValue((object?)descriptor.Homepage ?? DBNull.Value);
            plugin.Parameters.AddWithValue((object?)descriptor.Repository ?? DBNull.Value);
            plugin.Parameters.AddWithValue(publishedAt);
            await plugin.ExecuteNonQueryAsync(cancellationToken);
        }

        await using (NpgsqlCommand version = new(
            """
            INSERT INTO viget_tazel_plugin_versions (publisher, name, version, sha256, api_version, size, published_at)
            VALUES ($1, $2, $3, $4, $5, $6, $7)
            ON CONFLICT (publisher, name, version) DO NOTHING
            """,
            connection,
            transaction))
        {
            version.Parameters.AddWithValue(descriptor.Publisher);
            version.Parameters.AddWithValue(descriptor.Name);
            version.Parameters.AddWithValue(descriptor.Version);
            version.Parameters.AddWithValue(sha256);
            version.Parameters.AddWithValue(descriptor.ApiVersion);
            version.Parameters.AddWithValue(size);
            version.Parameters.AddWithValue(publishedAt);
            if (await version.ExecuteNonQueryAsync(cancellationToken) == 0)
            {
                // The plugin row is left as it was: the transaction is rolled back with the refusal.
                return AddVersionResult.VersionExists;
            }
        }

        await transaction.CommitAsync(cancellationToken);
        return AddVersionResult.Added;
    }

    /// <inheritdoc/>
    public async ValueTask<bool> SetYankedAsync(
        string publisher,
        string name,
        string version,
        bool yanked,
        CancellationToken cancellationToken)
    {
        await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken);
        await using NpgsqlCommand command = new(
            """
            UPDATE viget_tazel_plugin_versions SET yanked = $4
            WHERE publisher = $1 AND name = $2 AND version = $3
            """,
            connection);
        command.Parameters.AddWithValue(publisher);
        command.Parameters.AddWithValue(name);
        command.Parameters.AddWithValue(version);
        command.Parameters.AddWithValue(yanked);
        return await command.ExecuteNonQueryAsync(cancellationToken) == 1;
    }

    /// <summary>A plugin from a row of <see cref="PluginColumns"/>.</summary>
    /// <param name="reader">A reader on the row.</param>
    /// <param name="versions">The versions of the plugin.</param>
    /// <returns>The plugin.</returns>
    private static TazelPlugin ReadPlugin(NpgsqlDataReader reader, IReadOnlyList<TazelPluginVersion> versions) => new(
        reader.GetString(0),
        reader.GetString(1),
        reader.IsDBNull(2) ? null : reader.GetString(2),
        reader.GetFieldValue<string[]>(3),
        reader.IsDBNull(4) ? null : reader.GetString(4),
        reader.IsDBNull(5) ? null : reader.GetString(5),
        reader.IsDBNull(6) ? null : reader.GetString(6),
        versions);

    /// <summary>The versions of a plugin, newest publication first.</summary>
    /// <param name="connection">The connection to read on.</param>
    /// <param name="transaction">The transaction to read in, if any.</param>
    /// <param name="publisher">The publisher of the plugin.</param>
    /// <param name="name">The name of the plugin.</param>
    /// <param name="cancellationToken">Cancels the operation.</param>
    /// <returns>The versions.</returns>
    private static async Task<IReadOnlyList<TazelPluginVersion>> VersionsAsync(
        NpgsqlConnection connection,
        NpgsqlTransaction? transaction,
        string publisher,
        string name,
        CancellationToken cancellationToken)
    {
        await using NpgsqlCommand command = new(
            """
            SELECT version, sha256, api_version, size, published_at, yanked FROM viget_tazel_plugin_versions
            WHERE publisher = $1 AND name = $2 ORDER BY published_at DESC, version DESC
            """,
            connection,
            transaction);
        command.Parameters.AddWithValue(publisher);
        command.Parameters.AddWithValue(name);
        await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
        List<TazelPluginVersion> versions = [];
        while (await reader.ReadAsync(cancellationToken))
        {
            versions.Add(new TazelPluginVersion(
                reader.GetString(0),
                reader.GetString(1),
                reader.GetInt32(2),
                reader.GetInt64(3),
                reader.GetFieldValue<DateTimeOffset>(4),
                reader.GetBoolean(5)));
        }

        return versions;
    }
}