feat(api): bind encrypted workspaces to accounts and devices

This commit is contained in:
justme committed 2026-10-13 01:38:19 +08:00
1 parent 019b15041a
commit c7d8ef15bc
49 files changed
+1904 -278

No files matched your search

+100 -40
View File
@@ -10,7 +10,7 @@ namespace WebDrop.Storage;
/// </summary>
public sealed class SqliteChannelStore : IResourceStore, IDisposable
{
private const int SchemaVersion = 3;
private const int SchemaVersion = 1;
private readonly string _connectionString;
private readonly SqliteConnection _writeConn;
@@ -31,7 +31,20 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
_writeConn = Open();
Exec(_writeConn, "PRAGMA journal_mode=WAL;");
Migrate();
InitializeSchema();
using (var columns = _writeConn.CreateCommand())
{
columns.CommandText = "SELECT COUNT(*) FROM pragma_table_info('seats') WHERE name='account_device_id'";
if ((long)columns.ExecuteScalar()! == 0) Exec(_writeConn, "ALTER TABLE seats ADD COLUMN account_device_id TEXT;");
}
// Additive table: retain the running v2 database and its existing channel data.
Exec(_writeConn, """
CREATE TABLE IF NOT EXISTS channel_key_packages (
channel_id BLOB NOT NULL, username TEXT NOT NULL, payload TEXT NOT NULL,
created_at INTEGER NOT NULL, PRIMARY KEY (channel_id, username)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS ix_channel_keys_username ON channel_key_packages(username);
""");
}
public T Read<T>(Func<IResourceTx, T> action)
@@ -65,7 +78,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public void Dispose()
{
_writeConn.Dispose();
SqliteConnection.ClearAllPools();
SqliteConnection.ClearPool(_writeConn);
}
private SqliteConnection Open()
@@ -83,23 +96,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
cmd.ExecuteNonQuery();
}
private void Migrate()
private void InitializeSchema()
{
using var cmd = _writeConn.CreateCommand();
cmd.CommandText = "PRAGMA user_version;";
long version = (long)cmd.ExecuteScalar()!;
if (version > SchemaVersion) throw new InvalidOperationException("数据库版本高于本程序支持的版本");
if (version == SchemaVersion) return;
if (version != 0) throw new InvalidOperationException("存储版本不兼容当前应用契约");
using var tx = _writeConn.BeginTransaction();
if (version == 0) Exec(_writeConn, SchemaV1);
if (version < 2) Exec(_writeConn, SchemaV2);
if (version < 3) Exec(_writeConn, SchemaV3);
Exec(_writeConn, Schema);
Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};");
tx.Commit();
}
private const string SchemaV1 = """
private const string Schema = """
CREATE TABLE channels (
id BLOB NOT NULL PRIMARY KEY,
mode INTEGER NOT NULL,
@@ -111,7 +120,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
expires_at INTEGER NOT NULL,
last_seq INTEGER NOT NULL DEFAULT 0,
compacted_through INTEGER NOT NULL DEFAULT 0,
bytes INTEGER NOT NULL DEFAULT 0
bytes INTEGER NOT NULL DEFAULT 0,
creator_account TEXT NOT NULL,
join_password_hash BLOB,
join_password_salt BLOB
);
CREATE INDEX ix_channels_expires ON channels(expires_at);
@@ -119,6 +131,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
channel_id BLOB NOT NULL,
endpoint_hash BLOB NOT NULL,
label BLOB,
account_name TEXT,
joined_at INTEGER NOT NULL,
PRIMARY KEY (channel_id, endpoint_hash)
) WITHOUT ROWID;
@@ -127,6 +140,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
channel_id BLOB NOT NULL,
item_id BLOB NOT NULL,
author_hash BLOB NOT NULL,
author_account TEXT,
flags INTEGER NOT NULL,
max_downloads INTEGER NOT NULL,
downloads INTEGER NOT NULL,
@@ -137,7 +151,6 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
close_deadline INTEGER,
label TEXT,
PRIMARY KEY (channel_id, item_id)
);
CREATE INDEX ix_items_channel_created ON items(channel_id, created_at);
@@ -168,9 +181,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
PRIMARY KEY (channel_id, seq)
) WITHOUT ROWID;
CREATE INDEX ix_log_item ON log(channel_id, item_id);
""";
private const string SchemaV2 = """
CREATE TABLE shares (
id BLOB NOT NULL PRIMARY KEY,
settings TEXT NOT NULL,
@@ -181,7 +192,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
expires_at INTEGER NOT NULL,
sealed_at INTEGER,
manifest_item_id BLOB,
bytes INTEGER NOT NULL DEFAULT 0
bytes INTEGER NOT NULL DEFAULT 0,
creator_account TEXT NOT NULL
);
CREATE INDEX ix_shares_expires ON shares(expires_at);
@@ -222,9 +234,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
PRIMARY KEY (share_id, item_id, frame_index)
) WITHOUT ROWID;
CREATE INDEX ix_share_frames_blob ON share_frames(blob_key) WHERE blob_key IS NOT NULL;
""";
private const string SchemaV3 = """
CREATE TABLE download_grants (
resource_id BLOB NOT NULL,
item_id BLOB NOT NULL,
@@ -232,12 +242,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
PRIMARY KEY (resource_id, item_id, endpoint_hash)
) WITHOUT ROWID;
CREATE INDEX ix_download_grants_resource ON download_grants(resource_id);
CREATE TABLE channel_invitations(channel_id BLOB NOT NULL,username TEXT NOT NULL,payload TEXT NOT NULL,created_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username));
CREATE INDEX ix_channel_invitations_username ON channel_invitations(username);
CREATE TABLE channel_join_denials(channel_id BLOB NOT NULL,target TEXT NOT NULL,PRIMARY KEY(channel_id,target));
CREATE TABLE channel_members(channel_id BLOB NOT NULL,username TEXT NOT NULL,role TEXT NOT NULL,joined_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username));
""";
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
{
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes";
private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, label";
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes, creator_account, join_password_hash, join_password_salt";
private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, author_account";
private const string FrameCols = "channel_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key";
private const string ShareItemCols = "share_id, item_id, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline";
private const string ShareFrameCols = "share_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key";
@@ -291,10 +307,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
IdAt(r, 0),
JsonSerializer.Deserialize(r.GetString(1), CoreJsonContext.Default.ShareSettings)!,
Blob(r, 2), BlobOrNull(r, 3), BlobOrNull(r, 4), r.GetInt64(5), r.GetInt64(6),
LongOrNull(r, 7), r.IsDBNull(8) ? null : IdAt(r, 8), r.GetInt64(9));
LongOrNull(r, 7), r.IsDBNull(8) ? null : IdAt(r, 8), r.GetInt64(9), r.GetString(10));
public Share? GetShare(Id16 id) => One(
"SELECT id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes FROM shares WHERE id=@id",
"SELECT id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes,creator_account FROM shares WHERE id=@id",
MapShare, ("@id", id.ToArray()));
public bool HasResourceId(Id16 id) => Scalar(
@@ -302,17 +318,17 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
("@id", id.ToArray())) is not null;
public void InsertShare(Share s) => Run(
"INSERT INTO shares (id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes) VALUES (@id,@settings,@read,@owner,@creator,@created,@expires,@sealed,@manifest,@bytes)",
"INSERT INTO shares (id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes,creator_account) VALUES (@id,@settings,@read,@owner,@creator,@created,@expires,@sealed,@manifest,@bytes,@account)",
("@id", s.Id.ToArray()), ("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)),
("@read", s.ReadHash), ("@owner", s.OwnerHash), ("@creator", s.CreatorEndpointHash),
("@created", s.CreatedAt), ("@expires", s.ExpiresAt), ("@sealed", s.SealedAt),
("@manifest", s.ManifestItemId?.ToArray()), ("@bytes", s.Bytes));
("@manifest", s.ManifestItemId?.ToArray()), ("@bytes", s.Bytes), ("@account", s.CreatorAccount));
public void UpdateShare(Share s) => Run(
"UPDATE shares SET settings=@settings,owner_hash=@owner,expires_at=@expires,sealed_at=@sealed,manifest_item_id=@manifest,bytes=@bytes WHERE id=@id",
"UPDATE shares SET settings=@settings,owner_hash=@owner,expires_at=@expires,sealed_at=@sealed,manifest_item_id=@manifest,bytes=@bytes,creator_account=@account WHERE id=@id",
("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)), ("@owner", s.OwnerHash),
("@expires", s.ExpiresAt), ("@sealed", s.SealedAt), ("@manifest", s.ManifestItemId?.ToArray()),
("@bytes", s.Bytes), ("@id", s.Id.ToArray()));
("@bytes", s.Bytes), ("@id", s.Id.ToArray()), ("@account", s.CreatorAccount));
public IReadOnlyList<Id16> ListExpiredShares(long now, int limit) => Many(
"SELECT id FROM shares WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit));
@@ -400,18 +416,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
BlobOrNull(r, 3),
BlobOrNull(r, 4),
Blob(r, 5),
r.GetInt64(6), r.GetInt64(7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10));
r.GetInt64(6), r.GetInt64(7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10), r.GetString(11), BlobOrNull(r, 12), BlobOrNull(r, 13));
public Channel? GetChannel(Id16 id) =>
One($"SELECT {ChannelCols} FROM channels WHERE id=@id", MapChannel, ("@id", id.ToArray()));
public void InsertChannel(Channel c) => Run(
$"INSERT INTO channels ({ChannelCols}) VALUES (@id,@mode,@settings,@rh,@wh,@oh,@created,@expires,@seq,@comp,@bytes)",
$"INSERT INTO channels ({ChannelCols}) VALUES (@id,@mode,@settings,@rh,@wh,@oh,@created,@expires,@seq,@comp,@bytes,@account,@ph,@ps)",
("@id", c.Id.ToArray()), ("@mode", (int)c.Mode),
("@settings", JsonSerializer.Serialize(c.Settings, CoreJsonContext.Default.ChannelSettings)),
("@rh", c.ReadHash), ("@wh", c.WriteHash), ("@oh", c.OwnerHash),
("@created", c.CreatedAt), ("@expires", c.ExpiresAt),
("@seq", c.LastSeq), ("@comp", c.CompactedThrough), ("@bytes", c.Bytes));
("@seq", c.LastSeq), ("@comp", c.CompactedThrough), ("@bytes", c.Bytes), ("@account", c.CreatorAccount), ("@ph", c.JoinPasswordHash), ("@ps", c.JoinPasswordSalt));
public void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt) => Run(
"UPDATE channels SET settings=@s, expires_at=@e WHERE id=@id",
@@ -421,6 +437,36 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public void TouchChannel(Id16 id, long expiresAt) =>
Run("UPDATE channels SET expires_at=@e WHERE id=@id", ("@e", expiresAt), ("@id", id.ToArray()));
public void SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt) => Run("UPDATE channels SET join_password_hash=@h,join_password_salt=@s WHERE id=@id", ("@h", hash), ("@s", salt), ("@id", id.ToArray()));
public bool HasInvitation(Id16 id, string username) => Scalar("SELECT 1 FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username)) is not null;
public void PutInvitation(ChannelInvitation i) => Run("INSERT INTO channel_invitations(channel_id,username,payload,created_at) VALUES(@id,@u,@p,@t) ON CONFLICT(channel_id,username) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at", ("@id", i.ChannelId.ToArray()), ("@u", i.Username), ("@p", i.Payload), ("@t", i.CreatedAt));
private static ChannelInvitation MapInvitation(SqliteDataReader r) => new(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetInt64(3));
public IReadOnlyList<ChannelInvitation> ListInvitations(Id16 id) => Many("SELECT channel_id,username,payload,created_at FROM channel_invitations WHERE channel_id=@id", MapInvitation, ("@id", id.ToArray()));
public IReadOnlyList<ChannelInvitation> ListUserInvitations(string username) => Many("SELECT i.channel_id,i.username,i.payload,i.created_at FROM channel_invitations i JOIN channels c ON c.id=i.channel_id WHERE i.username=@u AND c.expires_at>@now AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id=i.channel_id AND m.username=i.username)", MapInvitation, ("@u", username), ("@now", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()));
public void DeleteInvitation(Id16 id, string username) => Run("DELETE FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username));
public void PutKeyPackage(Id16 id, string username, string payload, long createdAt) => Run(
"INSERT INTO channel_key_packages(channel_id,username,payload,created_at) VALUES(@id,@u,@p,@t) ON CONFLICT(channel_id,username) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at",
("@id", id.ToArray()), ("@u", username), ("@p", payload), ("@t", createdAt));
public void DeleteKeyPackage(Id16 id, string username) => Run("DELETE FROM channel_key_packages WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username));
public IReadOnlyList<ChannelKeyPackage> ListUserKeyPackages(string username, long now) => Many(
"""
SELECT k.channel_id,k.username,m.role,k.payload,k.created_at FROM channel_key_packages k
JOIN channels c ON c.id=k.channel_id
JOIN channel_members m ON m.channel_id=k.channel_id AND m.username=k.username
WHERE k.username=@u AND c.expires_at>@now
""", r => new ChannelKeyPackage(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetInt64(4)),
("@u", username), ("@now", now));
public void DeleteAccountSeats(Id16 id, string username) => Run("DELETE FROM seats WHERE channel_id=@id AND account_name=@u", ("@id", id.ToArray()), ("@u", username));
private static ChannelMember MapMember(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetInt64(2));
public ChannelMember? GetChannelMember(Id16 id, string username) => One("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id AND username=@u", MapMember, ("@id", id.ToArray()), ("@u", username));
public IReadOnlyList<ChannelMember> ListChannelMembers(Id16 id) => Many("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id ORDER BY joined_at,username", MapMember, ("@id", id.ToArray()));
public void PutChannelMember(Id16 id, ChannelMember member) => Run("INSERT OR IGNORE INTO channel_members(channel_id,username,role,joined_at) VALUES(@id,@u,@r,@t)", ("@id", id.ToArray()), ("@u", member.Username), ("@r", member.Role), ("@t", member.JoinedAt));
public void DeleteChannelMember(Id16 id, string username) => Run("DELETE FROM channel_members WHERE channel_id=@id AND username=@u AND role<>'owner'", ("@id", id.ToArray()), ("@u", username));
public bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash) => Scalar("SELECT 1 FROM channel_join_denials WHERE channel_id=@id AND (target=@a OR target=@e) LIMIT 1", ("@id", id.ToArray()), ("@a", username is null ? null : "account:" + username), ("@e", "endpoint:" + Codec.Encode(endpointHash))) is not null;
public void DenyAccount(Id16 id, string username) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "account:" + username));
public void AllowAccount(Id16 id, string username) => Run("DELETE FROM channel_join_denials WHERE channel_id=@id AND target=@t", ("@id", id.ToArray()), ("@t", "account:" + username));
public void DenyEndpoint(Id16 id, byte[] endpointHash) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "endpoint:" + Codec.Encode(endpointHash)));
public void AddChannelBytes(Id16 id, long delta) =>
Run("UPDATE channels SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray()));
@@ -439,6 +485,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
Run("DELETE FROM items WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM seats WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channels WHERE id=@c", ("@c", key));
Run("DELETE FROM channel_invitations WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channel_members WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key));
}
public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) =>
@@ -446,18 +496,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
// ───────── 席位 ─────────
private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), BlobOrNull(r, 2));
private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), BlobOrNull(r, 2), r.IsDBNull(3) ? null : r.GetString(3), r.IsDBNull(4) ? null : r.GetString(4));
public Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => One(
"SELECT endpoint_hash, joined_at, label FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
"SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
MapSeat, ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray()));
public int CountSeats(Id16 channelId) =>
(int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
public void InsertSeat(Id16 channelId, Seat seat) => Run(
"INSERT INTO seats (channel_id, endpoint_hash, label, joined_at) VALUES (@c,@e,@l,@j)",
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt));
"INSERT INTO seats (channel_id, endpoint_hash, label, joined_at,account_name,account_device_id) VALUES (@c,@e,@l,@j,@a,@d)",
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId));
public bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => Run(
"DELETE FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
@@ -468,27 +518,36 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
("@l", label), ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray()));
public IReadOnlyList<Seat> ListSeats(Id16 channelId) => Many(
"SELECT endpoint_hash, joined_at, label FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash",
"SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash",
MapSeat, ("@c", channelId.ToArray()));
public IReadOnlyList<Id16> DeleteDeviceSeats(string username, string deviceId)
{
var ids = Many("SELECT DISTINCT channel_id FROM seats WHERE account_name=@u AND account_device_id=@d", r => IdAt(r, 0), ("@u", username), ("@d", deviceId));
Run("DELETE FROM seats WHERE account_name=@u AND account_device_id=@d", ("@u", username), ("@d", deviceId));
return ids;
}
public void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId) => Run(
"UPDATE seats SET account_device_id=@d WHERE channel_id=@c AND endpoint_hash=@e",
("@d", deviceId), ("@c", id.ToArray()), ("@e", endpointHash));
// ───────── 条目 ─────────
private static Item MapItem(SqliteDataReader r) => new(
IdAt(r, 0), IdAt(r, 1), Blob(r, 2), (FrameFlags)r.GetInt32(3),
r.GetInt64(4), r.GetInt64(5), r.GetInt32(6), r.GetInt64(7), LongOrNull(r, 8),
r.GetInt64(9), r.GetInt64(10), r.GetInt64(11), LongOrNull(r, 12),
r.IsDBNull(13) ? null : r.GetString(13));
r.GetInt64(9), r.GetInt64(10), r.GetInt64(11), LongOrNull(r, 12), r.IsDBNull(13) ? null : r.GetString(13));
public Item? GetItem(Id16 channelId, Id16 itemId) => One(
$"SELECT {ItemCols} FROM items WHERE channel_id=@c AND item_id=@i",
MapItem, ("@c", channelId.ToArray()), ("@i", itemId.ToArray()));
public void InsertItem(Item i) => Run(
$"INSERT INTO items ({ItemCols}) VALUES (@c,@i,@a,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd,@l)",
$"INSERT INTO items ({ItemCols}) VALUES (@c,@i,@a,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd,@account)",
("@c", i.ChannelId.ToArray()), ("@i", i.ItemId.ToArray()), ("@a", i.AuthorHash),
("@f", (int)i.Flags), ("@md", i.MaxDownloads), ("@d", i.Downloads),
("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex),
("@b", i.Bytes), ("@cr", i.CreatedAt), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@l", i.Label));
("@b", i.Bytes), ("@cr", i.CreatedAt), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@account", i.AuthorAccount));
public void UpdateItem(Item i) => Run(
"UPDATE items SET downloads=@d, frame_count=@fc, max_index=@mi, final_index=@fi, bytes=@b, expires_at=@ex, close_deadline=@cd WHERE channel_id=@c AND item_id=@i",
@@ -507,10 +566,11 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
}
public int CountItems(Id16 channelId) =>
(int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
(int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c AND (flags & 16)=0", ("@c", channelId.ToArray()))!;
public int CountControlItems(Id16 channelId) => (int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c AND (flags & 16)<>0", ("@c", channelId.ToArray()))!;
public IReadOnlyList<Item> ListOldestItems(Id16 channelId, int limit) => Many(
$"SELECT {ItemCols} FROM items WHERE channel_id=@c ORDER BY created_at, rowid LIMIT @n",
$"SELECT {ItemCols} FROM items WHERE channel_id=@c AND (flags & 16)=0 ORDER BY created_at, rowid LIMIT @n",
MapItem, ("@c", channelId.ToArray()), ("@n", limit));
public Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId) => One(