650 lines
39 KiB
C#
650 lines
39 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Data.Sqlite;
|
|
using WebDrop.Core;
|
|
|
|
namespace WebDrop.Storage;
|
|
|
|
/// <summary>
|
|
/// SQLite 实现:WAL 模式,写入由单个连接加锁串行执行(保证 seq 严格连续),读取使用独立连接。
|
|
/// 全部使用手写 SQL 与显式映射,不依赖反射,可 NativeAOT 发布。
|
|
/// </summary>
|
|
public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
|
{
|
|
private const int SchemaVersion = 1;
|
|
|
|
private readonly string _connectionString;
|
|
private readonly SqliteConnection _writeConn;
|
|
private readonly object _writeLock = new();
|
|
|
|
public SqliteChannelStore(string dbPath)
|
|
{
|
|
string? dir = Path.GetDirectoryName(Path.GetFullPath(dbPath));
|
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
|
|
|
_connectionString = new SqliteConnectionStringBuilder
|
|
{
|
|
DataSource = dbPath,
|
|
Mode = SqliteOpenMode.ReadWriteCreate,
|
|
Cache = SqliteCacheMode.Private,
|
|
Pooling = true,
|
|
}.ToString();
|
|
|
|
_writeConn = Open();
|
|
Exec(_writeConn, "PRAGMA journal_mode=WAL;");
|
|
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);
|
|
CREATE TABLE IF NOT EXISTS workspace_structures (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
|
|
CREATE TABLE IF NOT EXISTS workspace_crypto (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
|
|
""");
|
|
}
|
|
|
|
public T Read<T>(Func<IResourceTx, T> action)
|
|
{
|
|
using var conn = Open();
|
|
// 显式事务让一次 Read 内的多条查询看到同一份快照
|
|
// 读事务延迟到首次访问时才取快照,避免并发读预检抢占 SQLite 写锁。
|
|
using var tx = conn.BeginTransaction(deferred: true);
|
|
return action(new SqliteTx(conn));
|
|
}
|
|
|
|
public T Write<T>(Func<IResourceTx, T> action)
|
|
{
|
|
lock (_writeLock)
|
|
{
|
|
using var tx = _writeConn.BeginTransaction();
|
|
try
|
|
{
|
|
T result = action(new SqliteTx(_writeConn));
|
|
tx.Commit();
|
|
return result;
|
|
}
|
|
catch
|
|
{
|
|
tx.Rollback();
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_writeConn.Dispose();
|
|
SqliteConnection.ClearPool(_writeConn);
|
|
}
|
|
|
|
private SqliteConnection Open()
|
|
{
|
|
var conn = new SqliteConnection(_connectionString);
|
|
conn.Open();
|
|
Exec(conn, "PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=OFF;");
|
|
return conn;
|
|
}
|
|
|
|
private static void Exec(SqliteConnection conn, string sql)
|
|
{
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
private void InitializeSchema()
|
|
{
|
|
using var cmd = _writeConn.CreateCommand();
|
|
cmd.CommandText = "PRAGMA user_version;";
|
|
long version = (long)cmd.ExecuteScalar()!;
|
|
if (version == SchemaVersion) return;
|
|
if (version != 0) throw new InvalidOperationException("存储版本不兼容当前应用契约");
|
|
using var tx = _writeConn.BeginTransaction();
|
|
Exec(_writeConn, Schema);
|
|
Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};");
|
|
tx.Commit();
|
|
}
|
|
private const string Schema = """
|
|
CREATE TABLE channels (
|
|
id BLOB NOT NULL PRIMARY KEY,
|
|
mode INTEGER NOT NULL,
|
|
settings TEXT NOT NULL,
|
|
read_hash BLOB,
|
|
write_hash BLOB,
|
|
owner_hash BLOB NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
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,
|
|
creator_account TEXT NOT NULL,
|
|
join_password_hash BLOB,
|
|
join_password_salt BLOB
|
|
);
|
|
CREATE INDEX ix_channels_expires ON channels(expires_at);
|
|
|
|
CREATE TABLE seats (
|
|
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;
|
|
|
|
CREATE TABLE items (
|
|
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,
|
|
frame_count INTEGER NOT NULL,
|
|
max_index INTEGER NOT NULL,
|
|
final_index INTEGER,
|
|
bytes INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
close_deadline INTEGER,
|
|
PRIMARY KEY (channel_id, item_id)
|
|
);
|
|
CREATE INDEX ix_items_channel_created ON items(channel_id, created_at);
|
|
CREATE INDEX ix_items_expires ON items(expires_at);
|
|
CREATE INDEX ix_items_close ON items(close_deadline) WHERE close_deadline IS NOT NULL;
|
|
|
|
CREATE TABLE frames (
|
|
channel_id BLOB NOT NULL,
|
|
item_id BLOB NOT NULL,
|
|
frame_index INTEGER NOT NULL,
|
|
seq INTEGER NOT NULL,
|
|
length INTEGER NOT NULL,
|
|
digest BLOB NOT NULL,
|
|
inline_blob BLOB,
|
|
blob_key TEXT,
|
|
PRIMARY KEY (channel_id, item_id, frame_index)
|
|
) WITHOUT ROWID;
|
|
CREATE INDEX ix_frames_blob ON frames(blob_key) WHERE blob_key IS NOT NULL;
|
|
|
|
CREATE TABLE log (
|
|
channel_id BLOB NOT NULL,
|
|
seq INTEGER NOT NULL,
|
|
kind INTEGER NOT NULL,
|
|
item_id BLOB NOT NULL,
|
|
frame_index INTEGER NOT NULL,
|
|
aux INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
PRIMARY KEY (channel_id, seq)
|
|
) WITHOUT ROWID;
|
|
CREATE INDEX ix_log_item ON log(channel_id, item_id);
|
|
|
|
CREATE TABLE shares (
|
|
id BLOB NOT NULL PRIMARY KEY,
|
|
settings TEXT NOT NULL,
|
|
read_hash BLOB NOT NULL,
|
|
owner_hash BLOB,
|
|
creator_endpoint_hash BLOB,
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
sealed_at INTEGER,
|
|
manifest_item_id BLOB,
|
|
bytes INTEGER NOT NULL DEFAULT 0,
|
|
creator_account TEXT NOT NULL
|
|
);
|
|
CREATE INDEX ix_shares_expires ON shares(expires_at);
|
|
|
|
CREATE TABLE share_visitors (
|
|
share_id BLOB NOT NULL,
|
|
endpoint_hash BLOB NOT NULL,
|
|
joined_at INTEGER NOT NULL,
|
|
PRIMARY KEY (share_id, endpoint_hash)
|
|
) WITHOUT ROWID;
|
|
|
|
CREATE TABLE share_items (
|
|
share_id BLOB NOT NULL,
|
|
item_id BLOB NOT NULL,
|
|
flags INTEGER NOT NULL,
|
|
max_downloads INTEGER NOT NULL,
|
|
downloads INTEGER NOT NULL,
|
|
frame_count INTEGER NOT NULL,
|
|
max_index INTEGER NOT NULL,
|
|
final_index INTEGER,
|
|
bytes INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
close_deadline INTEGER,
|
|
PRIMARY KEY (share_id, item_id)
|
|
) WITHOUT ROWID;
|
|
CREATE INDEX ix_share_items_expires ON share_items(expires_at);
|
|
CREATE INDEX ix_share_items_close ON share_items(close_deadline) WHERE close_deadline IS NOT NULL;
|
|
|
|
CREATE TABLE share_frames (
|
|
share_id BLOB NOT NULL,
|
|
item_id BLOB NOT NULL,
|
|
frame_index INTEGER NOT NULL,
|
|
seq INTEGER NOT NULL,
|
|
length INTEGER NOT NULL,
|
|
digest BLOB NOT NULL,
|
|
inline_blob BLOB,
|
|
blob_key TEXT,
|
|
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;
|
|
|
|
CREATE TABLE download_grants (
|
|
resource_id BLOB NOT NULL,
|
|
item_id BLOB NOT NULL,
|
|
endpoint_hash BLOB NOT NULL,
|
|
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, creator_account, join_password_hash, join_password_salt";
|
|
public WorkspaceState? GetWorkspace(Id16 id) => One("SELECT state FROM workspace_structures WHERE space_id=@id",
|
|
r => JsonSerializer.Deserialize(r.GetString(0), WorkspaceJson.Default.WorkspaceState)!, ("@id", id.ToArray()));
|
|
public CryptoState GetWorkspaceCrypto(Id16 id) => One("SELECT state FROM workspace_crypto WHERE space_id=@id",
|
|
r => JsonSerializer.Deserialize(r.GetString(0), WorkspaceCryptoJson.Default.CryptoState)!, ("@id", id.ToArray())) ?? new CryptoState();
|
|
public void PutWorkspaceCrypto(Id16 id, CryptoState state) => Run(
|
|
"INSERT INTO workspace_crypto(space_id,state) VALUES(@id,@s) ON CONFLICT(space_id) DO UPDATE SET state=excluded.state",
|
|
("@id", id.ToArray()), ("@s", JsonSerializer.Serialize(state, WorkspaceCryptoJson.Default.CryptoState)));
|
|
public IReadOnlyList<Id16> ListAccountSpaces(string username, long now) => Many(
|
|
"SELECT c.id FROM channels c JOIN channel_members m ON m.channel_id=c.id WHERE m.username=@u AND c.expires_at>@n ORDER BY c.created_at DESC",
|
|
r => Id16.FromBytes((byte[])r[0]), ("@u", username), ("@n", now));
|
|
public void PutWorkspace(Id16 id, WorkspaceState state) => Run(
|
|
"INSERT INTO workspace_structures(space_id,state) VALUES(@id,@s) ON CONFLICT(space_id) DO UPDATE SET state=excluded.state",
|
|
("@id", id.ToArray()), ("@s", JsonSerializer.Serialize(state, WorkspaceJson.Default.WorkspaceState)));
|
|
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";
|
|
|
|
// ───────── 通用辅助 ─────────
|
|
|
|
private SqliteCommand Cmd(string sql, (string, object?)[] args)
|
|
{
|
|
var cmd = conn.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
foreach (var (name, value) in args) cmd.Parameters.AddWithValue(name, value ?? DBNull.Value);
|
|
return cmd;
|
|
}
|
|
|
|
private int Run(string sql, params (string, object?)[] args)
|
|
{
|
|
using var cmd = Cmd(sql, args);
|
|
return cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
private object? Scalar(string sql, params (string, object?)[] args)
|
|
{
|
|
using var cmd = Cmd(sql, args);
|
|
return cmd.ExecuteScalar();
|
|
}
|
|
|
|
private T? One<T>(string sql, Func<SqliteDataReader, T> map, params (string, object?)[] args) where T : class
|
|
{
|
|
using var cmd = Cmd(sql, args);
|
|
using var r = cmd.ExecuteReader();
|
|
return r.Read() ? map(r) : null;
|
|
}
|
|
|
|
private List<T> Many<T>(string sql, Func<SqliteDataReader, T> map, params (string, object?)[] args)
|
|
{
|
|
using var cmd = Cmd(sql, args);
|
|
using var r = cmd.ExecuteReader();
|
|
var list = new List<T>();
|
|
while (r.Read()) list.Add(map(r));
|
|
return list;
|
|
}
|
|
|
|
private static byte[] Blob(SqliteDataReader r, int i) => (byte[])r.GetValue(i);
|
|
private static byte[]? BlobOrNull(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : (byte[])r.GetValue(i);
|
|
private static long? LongOrNull(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : r.GetInt64(i);
|
|
private static Id16 IdAt(SqliteDataReader r, int i) => Id16.FromBytes(Blob(r, i));
|
|
|
|
// ───────── 便捷分享 ─────────
|
|
|
|
private static Share MapShare(SqliteDataReader r) => new(
|
|
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), 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,creator_account FROM shares WHERE id=@id",
|
|
MapShare, ("@id", id.ToArray()));
|
|
|
|
public bool HasResourceId(Id16 id) => Scalar(
|
|
"SELECT 1 FROM channels WHERE id=@id UNION ALL SELECT 1 FROM shares WHERE id=@id LIMIT 1",
|
|
("@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,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), ("@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,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()), ("@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));
|
|
|
|
public int CountShareVisitors(Id16 id) =>
|
|
(int)(long)Scalar("SELECT COUNT(*) FROM share_visitors WHERE share_id=@id", ("@id", id.ToArray()))!;
|
|
|
|
public bool HasShareVisitor(Id16 id, ReadOnlySpan<byte> endpointHash) => Scalar(
|
|
"SELECT 1 FROM share_visitors WHERE share_id=@id AND endpoint_hash=@ep",
|
|
("@id", id.ToArray()), ("@ep", endpointHash.ToArray())) is not null;
|
|
|
|
public void InsertShareVisitor(Id16 id, ReadOnlySpan<byte> endpointHash, long joinedAt) => Run(
|
|
"INSERT INTO share_visitors (share_id,endpoint_hash,joined_at) VALUES (@id,@ep,@at)",
|
|
("@id", id.ToArray()), ("@ep", endpointHash.ToArray()), ("@at", joinedAt));
|
|
|
|
public void DeleteShareRows(Id16 id)
|
|
{
|
|
byte[] key = id.ToArray();
|
|
Run("DELETE FROM download_grants WHERE resource_id=@id", ("@id", key));
|
|
Run("DELETE FROM share_frames WHERE share_id=@id", ("@id", key));
|
|
Run("DELETE FROM share_items WHERE share_id=@id", ("@id", key));
|
|
Run("DELETE FROM share_visitors WHERE share_id=@id", ("@id", key));
|
|
Run("DELETE FROM shares WHERE id=@id", ("@id", key));
|
|
}
|
|
|
|
private static ShareItem MapShareItem(SqliteDataReader r) => new(
|
|
IdAt(r, 0), IdAt(r, 1), (FrameFlags)r.GetInt32(2), r.GetInt64(3), r.GetInt64(4), r.GetInt32(5),
|
|
r.GetInt64(6), LongOrNull(r, 7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10), LongOrNull(r, 11));
|
|
|
|
public IReadOnlyList<ShareItem> ListShareItems(Id16 id) => Many(
|
|
$"SELECT {ShareItemCols} FROM share_items WHERE share_id=@id ORDER BY created_at,item_id", MapShareItem, ("@id", id.ToArray()));
|
|
public int CountShareItems(Id16 id) => (int)(long)Scalar("SELECT COUNT(*) FROM share_items WHERE share_id=@id", ("@id", id.ToArray()))!;
|
|
public void AddShareBytes(Id16 id, long delta) => Run("UPDATE shares SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray()));
|
|
public IReadOnlyList<ShareItem> ListExpiredShareItems(long now, int limit) => Many(
|
|
$"SELECT {ShareItemCols} FROM share_items WHERE expires_at<=@now OR (close_deadline IS NOT NULL AND close_deadline<=@now) LIMIT @n",
|
|
MapShareItem, ("@now", now), ("@n", limit));
|
|
|
|
public ShareItem? GetShareItem(Id16 shareId, Id16 itemId) => One(
|
|
$"SELECT {ShareItemCols} FROM share_items WHERE share_id=@s AND item_id=@i", MapShareItem,
|
|
("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
|
|
public void InsertShareItem(ShareItem i) => Run(
|
|
"INSERT INTO share_items (share_id,item_id,flags,max_downloads,downloads,frame_count,max_index,final_index,bytes,created_at,expires_at,close_deadline) VALUES (@s,@i,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd)",
|
|
("@s", i.ShareId.ToArray()), ("@i", i.ItemId.ToArray()), ("@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));
|
|
public void UpdateShareItem(ShareItem i) => Run(
|
|
"UPDATE share_items SET downloads=@d,frame_count=@fc,max_index=@mi,final_index=@fi,bytes=@b,expires_at=@ex,close_deadline=@cd WHERE share_id=@s AND item_id=@i",
|
|
("@d", i.Downloads), ("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex), ("@b", i.Bytes),
|
|
("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@s", i.ShareId.ToArray()), ("@i", i.ItemId.ToArray()));
|
|
public void DeleteShareItemRows(Id16 shareId, Id16 itemId)
|
|
{
|
|
Run("DELETE FROM download_grants WHERE resource_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
|
|
Run("DELETE FROM share_frames WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
|
|
Run("DELETE FROM share_items WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
|
|
}
|
|
|
|
public bool HasDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash) => Scalar(
|
|
"SELECT 1 FROM download_grants WHERE resource_id=@r AND item_id=@i AND endpoint_hash=@e",
|
|
("@r", resourceId.ToArray()), ("@i", itemId.ToArray()), ("@e", endpointHash.ToArray())) is not null;
|
|
|
|
public void AddDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash) => Run(
|
|
"INSERT OR IGNORE INTO download_grants (resource_id,item_id,endpoint_hash) VALUES (@r,@i,@e)",
|
|
("@r", resourceId.ToArray()), ("@i", itemId.ToArray()), ("@e", endpointHash.ToArray()));
|
|
|
|
private static ShareFrame MapShareFrame(SqliteDataReader r) => new(
|
|
IdAt(r, 0), IdAt(r, 1), (uint)r.GetInt64(2), r.GetInt64(3), r.GetInt32(4), Blob(r, 5), BlobOrNull(r, 6),
|
|
r.IsDBNull(7) ? null : r.GetString(7));
|
|
public ShareFrame? GetShareFrame(Id16 s, Id16 i, uint n) => One(
|
|
$"SELECT {ShareFrameCols} FROM share_frames WHERE share_id=@s AND item_id=@i AND frame_index=@n", MapShareFrame,
|
|
("@s", s.ToArray()), ("@i", i.ToArray()), ("@n", (long)n));
|
|
public void InsertShareFrame(ShareFrame f) => Run(
|
|
$"INSERT INTO share_frames ({ShareFrameCols}) VALUES (@s,@i,@n,@seq,@len,@digest,@inline,@key)",
|
|
("@s", f.ShareId.ToArray()), ("@i", f.ItemId.ToArray()), ("@n", (long)f.Index), ("@seq", f.AckSeq),
|
|
("@len", f.Length), ("@digest", f.Digest), ("@inline", f.Inline), ("@key", f.BlobKey));
|
|
public IReadOnlyList<ShareFrame> ListShareFrames(Id16 s, Id16 i, uint from, int limit) => Many(
|
|
$"SELECT {ShareFrameCols} FROM share_frames WHERE share_id=@s AND item_id=@i AND frame_index>=@from ORDER BY frame_index LIMIT @n",
|
|
MapShareFrame, ("@s", s.ToArray()), ("@i", i.ToArray()), ("@from", (long)from), ("@n", limit));
|
|
|
|
// ───────── 频道 ─────────
|
|
|
|
private static Channel MapChannel(SqliteDataReader r) => new(
|
|
IdAt(r, 0),
|
|
(ChannelMode)r.GetInt32(1),
|
|
JsonSerializer.Deserialize(r.GetString(2), CoreJsonContext.Default.ChannelSettings)!,
|
|
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.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,@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), ("@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",
|
|
("@s", JsonSerializer.Serialize(settings, CoreJsonContext.Default.ChannelSettings)),
|
|
("@e", expiresAt), ("@id", id.ToArray()));
|
|
|
|
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()));
|
|
|
|
public void SetCompactedThrough(Id16 id, long seq) =>
|
|
Run("UPDATE channels SET compacted_through=@s WHERE id=@id", ("@s", seq), ("@id", id.ToArray()));
|
|
|
|
public long NextSeq(Id16 id) =>
|
|
(long)Scalar("UPDATE channels SET last_seq=last_seq+1 WHERE id=@id RETURNING last_seq", ("@id", id.ToArray()))!;
|
|
|
|
public void DeleteChannelRows(Id16 id)
|
|
{
|
|
byte[] key = id.ToArray();
|
|
Run("DELETE FROM download_grants WHERE resource_id=@c", ("@c", key));
|
|
Run("DELETE FROM frames WHERE channel_id=@c", ("@c", key));
|
|
Run("DELETE FROM log WHERE channel_id=@c", ("@c", key));
|
|
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 workspace_structures WHERE space_id=@c", ("@c", key));
|
|
Run("DELETE FROM workspace_crypto WHERE space_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) =>
|
|
Many("SELECT id FROM channels WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit));
|
|
|
|
// ───────── 席位 ─────────
|
|
|
|
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, 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 int CountMembers(Id16 channelId) =>
|
|
(int)(long)Scalar("SELECT (SELECT COUNT(*) FROM channel_members WHERE channel_id=@c) + (SELECT COUNT(*) FROM seats WHERE channel_id=@c AND account_name IS NULL)", ("@c", channelId.ToArray()))!;
|
|
|
|
public void InsertSeat(Id16 channelId, Seat seat) => Run(
|
|
"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",
|
|
("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())) > 0;
|
|
|
|
public void SetSeatLabel(Id16 channelId, ReadOnlySpan<byte> endpointHash, byte[] label) => Run(
|
|
"UPDATE seats SET label=@l WHERE channel_id=@c AND endpoint_hash=@e",
|
|
("@l", label), ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray()));
|
|
|
|
public IReadOnlyList<Seat> ListSeats(Id16 channelId) => Many(
|
|
"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));
|
|
|
|
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,@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), ("@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",
|
|
("@d", i.Downloads), ("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex),
|
|
("@b", i.Bytes), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline),
|
|
("@c", i.ChannelId.ToArray()), ("@i", i.ItemId.ToArray()));
|
|
|
|
public void DeleteItemRows(Id16 channelId, Id16 itemId)
|
|
{
|
|
byte[] c = channelId.ToArray(), i = itemId.ToArray();
|
|
Run("DELETE FROM download_grants WHERE resource_id=@c AND item_id=@i", ("@c", c), ("@i", i));
|
|
Run("DELETE FROM frames WHERE channel_id=@c AND item_id=@i", ("@c", c), ("@i", i));
|
|
// 墓碑保留,其余记录(帧、announce)随条目一起删除
|
|
Run("DELETE FROM log WHERE channel_id=@c AND item_id=@i AND kind<>@t", ("@c", c), ("@i", i), ("@t", (int)LogKind.Tombstone));
|
|
Run("DELETE FROM items WHERE channel_id=@c AND item_id=@i", ("@c", c), ("@i", i));
|
|
}
|
|
|
|
public int CountItems(Id16 channelId) =>
|
|
(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 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(
|
|
$"SELECT {ItemCols} FROM items WHERE channel_id=@c AND item_id<>@x AND (flags & @s)<>0 ORDER BY created_at DESC, rowid DESC LIMIT 1",
|
|
MapItem, ("@c", channelId.ToArray()), ("@x", excludeItemId.ToArray()), ("@s", (int)FrameFlags.Snapshot));
|
|
|
|
public IReadOnlyList<Item> ListExpiredItems(long now, int limit) => Many(
|
|
$"SELECT {ItemCols} FROM items WHERE expires_at<=@now OR (close_deadline IS NOT NULL AND close_deadline<=@now) LIMIT @n",
|
|
MapItem, ("@now", now), ("@n", limit));
|
|
|
|
// ───────── 帧 ─────────
|
|
|
|
private static StoredFrame MapFrame(SqliteDataReader r) => new(
|
|
IdAt(r, 0), IdAt(r, 1), (uint)r.GetInt64(2), r.GetInt64(3), r.GetInt32(4),
|
|
Blob(r, 5), BlobOrNull(r, 6), r.IsDBNull(7) ? null : r.GetString(7));
|
|
|
|
public StoredFrame? GetFrame(Id16 channelId, Id16 itemId, uint index) => One(
|
|
$"SELECT {FrameCols} FROM frames WHERE channel_id=@c AND item_id=@i AND frame_index=@x",
|
|
MapFrame, ("@c", channelId.ToArray()), ("@i", itemId.ToArray()), ("@x", (long)index));
|
|
|
|
public void InsertFrame(StoredFrame f) => Run(
|
|
$"INSERT INTO frames ({FrameCols}) VALUES (@c,@i,@x,@s,@len,@dg,@inl,@bk)",
|
|
("@c", f.ChannelId.ToArray()), ("@i", f.ItemId.ToArray()), ("@x", (long)f.Index),
|
|
("@s", f.Seq), ("@len", f.Length), ("@dg", f.Digest), ("@inl", f.Inline), ("@bk", f.BlobKey));
|
|
|
|
public IReadOnlyList<StoredFrame> ListFrames(Id16 channelId, Id16 itemId, uint fromIndex, int limit) => Many(
|
|
$"SELECT {FrameCols} FROM frames WHERE channel_id=@c AND item_id=@i AND frame_index>=@x ORDER BY frame_index LIMIT @n",
|
|
MapFrame, ("@c", channelId.ToArray()), ("@i", itemId.ToArray()), ("@x", (long)fromIndex), ("@n", limit));
|
|
|
|
public bool BlobKeyExists(string blobKey) =>
|
|
Scalar("SELECT 1 FROM frames WHERE blob_key=@k UNION ALL SELECT 1 FROM share_frames WHERE blob_key=@k LIMIT 1", ("@k", blobKey)) is not null;
|
|
|
|
// ───────── 日志 ─────────
|
|
|
|
public void InsertLog(Id16 channelId, LogEntry e) => Run(
|
|
"INSERT INTO log (channel_id, seq, kind, item_id, frame_index, aux, created_at) VALUES (@c,@s,@k,@i,@x,@a,@t)",
|
|
("@c", channelId.ToArray()), ("@s", e.Seq), ("@k", (int)e.Kind), ("@i", e.ItemId.ToArray()),
|
|
("@x", (long)e.FrameIndex), ("@a", (int)e.Aux), ("@t", e.CreatedAt));
|
|
|
|
public IReadOnlyList<LogEntry> ReadLog(Id16 channelId, long fromSeq, int limit) => Many(
|
|
"SELECT seq, kind, item_id, frame_index, aux, created_at FROM log WHERE channel_id=@c AND seq>=@s ORDER BY seq LIMIT @n",
|
|
r => new LogEntry(r.GetInt64(0), (LogKind)r.GetInt32(1), IdAt(r, 2), (uint)r.GetInt64(3), (byte)r.GetInt32(4), r.GetInt64(5)),
|
|
("@c", channelId.ToArray()), ("@s", fromSeq), ("@n", limit));
|
|
|
|
public long PruneTombstones(Id16 channelId, long olderThanMs)
|
|
{
|
|
byte[] c = channelId.ToArray();
|
|
var max = Scalar("SELECT MAX(seq) FROM log WHERE channel_id=@c AND kind=@k AND created_at<@t",
|
|
("@c", c), ("@k", (int)LogKind.Tombstone), ("@t", olderThanMs));
|
|
if (max is not long seq) return 0;
|
|
Run("DELETE FROM log WHERE channel_id=@c AND kind=@k AND seq<=@s AND created_at<@t",
|
|
("@c", c), ("@k", (int)LogKind.Tombstone), ("@s", seq), ("@t", olderThanMs));
|
|
return seq;
|
|
}
|
|
}
|
|
}
|