mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 19:52:13 +09:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14ae64f5f3 | ||
|
|
990176ac7c | ||
|
|
69ef46356b | ||
|
|
bd88c7b04b | ||
|
|
03641f622f | ||
|
|
3b1eb56713 | ||
|
|
059bcc6c53 | ||
|
|
82043ce2a8 | ||
|
|
470e752fba | ||
|
|
3cc7b9050c | ||
|
|
b1f7c78c7e | ||
|
|
8f20b29b16 | ||
|
|
19efbd3d61 | ||
|
|
f35c0effa2 | ||
|
|
8363cc0844 | ||
|
|
5a11cacbae | ||
|
|
27151a9487 | ||
|
|
c152b25deb | ||
|
|
04d3afaf7b | ||
|
|
dbc63e9ef7 | ||
|
|
ce91ff95ab | ||
|
|
d75a29f099 | ||
|
|
cb889b1160 | ||
|
|
8db4f5cb63 | ||
|
|
d297dc5805 | ||
|
|
9b8659a40b | ||
|
|
96c5db3f5d | ||
|
|
78e403819e | ||
|
|
805acf4e5e | ||
|
|
9f099a9127 | ||
|
|
4d15c39cd7 | ||
|
|
827451d12d | ||
|
|
03603662c6 | ||
|
|
34eb10cd88 | ||
|
|
82941c08c9 | ||
|
|
8d21c97d08 | ||
|
|
71a56f590d | ||
|
|
38297c4fe7 | ||
|
|
cf7ec06d85 | ||
|
|
f2ea10f951 | ||
|
|
bbd93df239 | ||
|
|
9a6ab93e01 | ||
|
|
38eed7cce6 | ||
|
|
79064c3399 | ||
|
|
0496b2f530 | ||
|
|
9d0be1ebd1 | ||
|
|
9ad026b8ce | ||
|
|
14de5971d5 |
@@ -11854,7 +11854,14 @@
|
||||
"minimum": 0,
|
||||
"maximum": 7,
|
||||
"format": "int32",
|
||||
"description": "Number of days of messages to delete from the banned user (0-7)"
|
||||
"description": "Number of days of messages to delete from the banned user (0-7). Deprecated in favor of delete_message_seconds."
|
||||
},
|
||||
"delete_message_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 604800,
|
||||
"format": "int32",
|
||||
"description": "Number of seconds of messages to delete for the banned user (0-604800, default 0)"
|
||||
},
|
||||
"reason": {"description": "The reason for the ban (max 512 characters)", "nullable": true, "type": "string"},
|
||||
"ban_duration_seconds": {
|
||||
|
||||
@@ -136,6 +136,7 @@ impl AdminApiClient {
|
||||
let body = generated_types::BanGuildMemberRequest {
|
||||
ban_duration_seconds: None,
|
||||
delete_message_days: None,
|
||||
delete_message_seconds: None,
|
||||
guild_id: snowflake(guild_id),
|
||||
reason: None,
|
||||
user_id: snowflake(user_id),
|
||||
|
||||
@@ -2,24 +2,43 @@
|
||||
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
extract::Request,
|
||||
extract::{Request, State},
|
||||
http::{HeaderValue, Method, StatusCode, header},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use rand::RngExt;
|
||||
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::session::{create_csrf_token, verify_csrf_token};
|
||||
use crate::state::AppState;
|
||||
|
||||
const CSRF_COOKIE_NAME: &str = "csrf_token";
|
||||
pub const CSRF_FORM_FIELD: &str = "_csrf";
|
||||
const CSRF_HEADER_NAME: &str = "x-csrf-token";
|
||||
const TOKEN_LENGTH: usize = 32;
|
||||
const HOST_CSRF_COOKIE_NAME: &str = "__Host-csrf_token";
|
||||
const MAX_CSRF_FORM_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
const IGNORED_PATH_SUFFIXES: &[&str] = &["/oauth2_callback", "/auth/start"];
|
||||
|
||||
pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
|
||||
let existing_token = extract_csrf_cookie(&request);
|
||||
let token = existing_token.unwrap_or_else(generate_csrf_token);
|
||||
pub async fn csrf_protection(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let secret = config.secret_key_base.clone();
|
||||
let admin_endpoint = config.admin_endpoint.clone();
|
||||
let is_production = config.is_production();
|
||||
|
||||
let user_id = request
|
||||
.extensions()
|
||||
.get::<AuthContext>()
|
||||
.map(|ctx| ctx.session.user_id.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let token = extract_csrf_cookie(&request)
|
||||
.filter(|cookie| verify_csrf_token(cookie, &user_id, &secret))
|
||||
.unwrap_or_else(|| create_csrf_token(&user_id, &secret));
|
||||
request.extensions_mut().insert(CsrfToken(token.clone()));
|
||||
|
||||
if matches!(
|
||||
@@ -31,6 +50,9 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
|
||||
.iter()
|
||||
.any(|suffix| path.ends_with(suffix));
|
||||
if !is_ignored {
|
||||
if !is_same_site_request(&request, &admin_endpoint) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let header_token = extract_csrf_header(&request);
|
||||
let query_token = extract_csrf_from_query(&request);
|
||||
let mut submitted = query_token.or(header_token);
|
||||
@@ -43,21 +65,24 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
|
||||
request = restored_request;
|
||||
submitted = body_token;
|
||||
}
|
||||
match submitted {
|
||||
Some(ref submitted_token) if submitted_token == &token => {}
|
||||
_ => {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let accepted = submitted.as_deref().is_some_and(|submitted_token| {
|
||||
submitted_token == token && verify_csrf_token(submitted_token, &user_id, &secret)
|
||||
});
|
||||
if !accepted {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
let cookie_value = format!(
|
||||
"{}={}; Path=/; SameSite=Lax; HttpOnly",
|
||||
CSRF_COOKIE_NAME, token
|
||||
);
|
||||
let cookie_name = if is_production {
|
||||
HOST_CSRF_COOKIE_NAME
|
||||
} else {
|
||||
CSRF_COOKIE_NAME
|
||||
};
|
||||
let secure = if is_production { "; Secure" } else { "" };
|
||||
let cookie_value = format!("{cookie_name}={token}; Path=/; SameSite=Lax; HttpOnly{secure}");
|
||||
if let Ok(value) = HeaderValue::from_str(&cookie_value) {
|
||||
response.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
@@ -69,7 +94,10 @@ fn extract_csrf_cookie(request: &Request) -> Option<String> {
|
||||
let cookie_header = request.headers().get(header::COOKIE)?.to_str().ok()?;
|
||||
for pair in cookie_header.split(';') {
|
||||
let pair = pair.trim();
|
||||
if let Some(value) = pair.strip_prefix("csrf_token=") {
|
||||
let value = pair
|
||||
.strip_prefix("__Host-csrf_token=")
|
||||
.or_else(|| pair.strip_prefix("csrf_token="));
|
||||
if let Some(value) = value {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_owned());
|
||||
@@ -127,18 +155,22 @@ async fn extract_csrf_from_form_body(
|
||||
Ok((request, token))
|
||||
}
|
||||
|
||||
fn generate_csrf_token() -> String {
|
||||
let mut rng = rand::rng();
|
||||
let bytes: [u8; TOKEN_LENGTH] = rng.random();
|
||||
hex_encode(&bytes)
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
s.push_str(&format!("{byte:02x}"));
|
||||
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
|
||||
if let Some(site) = request
|
||||
.headers()
|
||||
.get("sec-fetch-site")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
return matches!(site, "same-origin" | "same-site" | "none");
|
||||
}
|
||||
match request
|
||||
.headers()
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
Some(origin) => origin == admin_endpoint,
|
||||
None => true,
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -156,40 +188,6 @@ pub fn get_csrf_token(request: &Request) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generate_csrf_token_correct_length() {
|
||||
let token = generate_csrf_token();
|
||||
assert_eq!(
|
||||
token.len(),
|
||||
TOKEN_LENGTH * 2,
|
||||
"token must be {} hex chars",
|
||||
TOKEN_LENGTH * 2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_csrf_token_is_valid_hex() {
|
||||
let token = generate_csrf_token();
|
||||
assert!(
|
||||
token.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"token must contain only hex chars: {token}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_csrf_token_is_unique() {
|
||||
let a = generate_csrf_token();
|
||||
let b = generate_csrf_token();
|
||||
assert_ne!(a, b, "consecutive tokens must differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_encode_produces_correct_output() {
|
||||
assert_eq!(hex_encode(&[0x00, 0xff, 0x0a]), "00ff0a");
|
||||
assert_eq!(hex_encode(&[]), "");
|
||||
assert_eq!(hex_encode(&[0xde, 0xad]), "dead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth2_callback_is_exempt() {
|
||||
let exempt = IGNORED_PATH_SUFFIXES
|
||||
|
||||
@@ -348,7 +348,7 @@ fn oauth_callback_page(config: &AdminConfig, code: Option<&str>, state: Option<&
|
||||
)
|
||||
}
|
||||
|
||||
fn json_string(value: &str) -> String {
|
||||
pub(crate) fn json_string(value: &str) -> String {
|
||||
serde_json::to_string(value)
|
||||
.expect("JSON string serialization cannot fail")
|
||||
.replace('<', "\\u003c")
|
||||
|
||||
@@ -73,7 +73,10 @@ pub fn build_router(config: AdminConfig) -> Router {
|
||||
.route("/", get(dashboard))
|
||||
.route("/dashboard", get(dashboard))
|
||||
.layer(from_fn(middleware::htmx::flash_redirect_to_toast))
|
||||
.layer(from_fn(middleware::csrf::csrf_protection))
|
||||
.layer(from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::csrf::csrf_protection,
|
||||
))
|
||||
.layer(from_fn(middleware::self_hosted::self_hosted_override))
|
||||
.layer(from_fn_with_state(
|
||||
state.clone(),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use rand::RngExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -78,6 +79,36 @@ fn verify_signature<'a>(signed_data: &'a str, secret_key: &str) -> Option<&'a st
|
||||
if diff == 0 { Some(data) } else { None }
|
||||
}
|
||||
|
||||
pub fn create_csrf_token(user_id: &str, secret_key: &str) -> String {
|
||||
let mut rng = rand::rng();
|
||||
let nonce: [u8; 16] = rng.random();
|
||||
let payload = URL_SAFE_NO_PAD.encode(format!(
|
||||
"{}:{}",
|
||||
URL_SAFE_NO_PAD.encode(user_id.as_bytes()),
|
||||
URL_SAFE_NO_PAD.encode(nonce)
|
||||
));
|
||||
sign_data(&payload, secret_key)
|
||||
}
|
||||
|
||||
pub fn verify_csrf_token(token: &str, user_id: &str, secret_key: &str) -> bool {
|
||||
let Some(data) = verify_signature(token, secret_key) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(decoded) = URL_SAFE_NO_PAD.decode(data.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(payload) = String::from_utf8(decoded) else {
|
||||
return false;
|
||||
};
|
||||
let Some((encoded_uid, _nonce)) = payload.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
match URL_SAFE_NO_PAD.decode(encoded_uid.as_bytes()) {
|
||||
Ok(uid_bytes) => uid_bytes == user_id.as_bytes(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const LEGACY_SESSION_COOKIE_NAME: &str = "session";
|
||||
pub const SESSION_COOKIE_NAME: &str = "admin_session";
|
||||
pub const SESSION_MAX_AGE: i64 = MAX_AGE_SECONDS as i64;
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::media::user_avatar_url;
|
||||
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
|
||||
use super::user_display::format_user_display;
|
||||
use crate::config::AdminConfig;
|
||||
use crate::routes::auth::json_string;
|
||||
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
@@ -309,7 +310,7 @@ pub fn message_list(
|
||||
}
|
||||
|
||||
pub fn message_deletion_script(csrf_token: &str) -> Markup {
|
||||
let csrf = serde_json::to_string(csrf_token).unwrap_or_else(|_| "\"\"".into());
|
||||
let csrf = json_string(csrf_token);
|
||||
let script = r#"(function() {
|
||||
var csrf = __CSRF__;
|
||||
function bp() {
|
||||
|
||||
@@ -89,29 +89,77 @@ function isFqdnHostname(hostname: string): boolean {
|
||||
return !/^\d+$/.test(topLevelDomain);
|
||||
}
|
||||
|
||||
function parseIpv4MappedIpv6Address(ipv6Address: string): string | null {
|
||||
function expandIpv6ToBytes(ipv6Address: string): Uint8Array | null {
|
||||
const normalized = stripIpv6Brackets(ipv6Address.trim().toLowerCase());
|
||||
if (!normalized.startsWith('::ffff:')) {
|
||||
if (isIP(normalized) !== 6) {
|
||||
return null;
|
||||
}
|
||||
const suffix = normalized.slice('::ffff:'.length);
|
||||
if (isIP(suffix) === 4) {
|
||||
return suffix;
|
||||
let head = normalized;
|
||||
let embeddedIpv4Octets: Array<number> | null = null;
|
||||
const lastColonIndex = head.lastIndexOf(':');
|
||||
const trailing = head.slice(lastColonIndex + 1);
|
||||
if (trailing.includes('.')) {
|
||||
if (isIP(trailing) !== 4) {
|
||||
return null;
|
||||
}
|
||||
embeddedIpv4Octets = trailing.split('.').map((part) => Number.parseInt(part, 10));
|
||||
head = `${head.slice(0, lastColonIndex + 1)}0:0`;
|
||||
}
|
||||
const groups = suffix.split(':');
|
||||
if (groups.length !== 2) {
|
||||
let groups: Array<string>;
|
||||
if (head.indexOf('::') === -1) {
|
||||
groups = head.split(':');
|
||||
if (groups.length !== 8) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const [beforePart, afterPart] = head.split('::');
|
||||
const before = beforePart.length > 0 ? beforePart.split(':') : [];
|
||||
const after = afterPart.length > 0 ? afterPart.split(':') : [];
|
||||
const missing = 8 - before.length - after.length;
|
||||
if (missing < 1) {
|
||||
return null;
|
||||
}
|
||||
groups = [...before, ...new Array(missing).fill('0'), ...after];
|
||||
}
|
||||
const bytes = new Uint8Array(16);
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const value = parseHexGroup(groups[index]);
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
bytes[index * 2] = (value >> 8) & 0xff;
|
||||
bytes[index * 2 + 1] = value & 0xff;
|
||||
}
|
||||
if (embeddedIpv4Octets) {
|
||||
bytes[12] = embeddedIpv4Octets[0];
|
||||
bytes[13] = embeddedIpv4Octets[1];
|
||||
bytes[14] = embeddedIpv4Octets[2];
|
||||
bytes[15] = embeddedIpv4Octets[3];
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseEmbeddedIpv4Address(ipv6Address: string): string | null {
|
||||
const bytes = expandIpv6ToBytes(ipv6Address);
|
||||
if (!bytes) {
|
||||
return null;
|
||||
}
|
||||
const high = parseHexGroup(groups[0]);
|
||||
const low = parseHexGroup(groups[1]);
|
||||
if (high === null || low === null) {
|
||||
return null;
|
||||
const hasPrefix = (prefix: Array<number>): boolean => prefix.every((byte, index) => bytes[index] === byte);
|
||||
const dottedQuadAt = (start: number): string =>
|
||||
`${bytes[start]}.${bytes[start + 1]}.${bytes[start + 2]}.${bytes[start + 3]}`;
|
||||
if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff])) {
|
||||
return dottedQuadAt(12);
|
||||
}
|
||||
const octet1 = (high >> 8) & 0xff;
|
||||
const octet2 = high & 0xff;
|
||||
const octet3 = (low >> 8) & 0xff;
|
||||
const octet4 = low & 0xff;
|
||||
return `${octet1}.${octet2}.${octet3}.${octet4}`;
|
||||
if (hasPrefix([0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0])) {
|
||||
return dottedQuadAt(12);
|
||||
}
|
||||
if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) {
|
||||
return dottedQuadAt(12);
|
||||
}
|
||||
if (hasPrefix([0x20, 0x02])) {
|
||||
return dottedQuadAt(2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHexGroup(value: string | undefined): number | null {
|
||||
@@ -128,9 +176,9 @@ function isBlockedIpAddress(address: string): boolean {
|
||||
return blockedIpv4List.check(normalizedAddress, 'ipv4');
|
||||
}
|
||||
if (family === 6) {
|
||||
const mappedIpv4 = parseIpv4MappedIpv6Address(normalizedAddress);
|
||||
if (mappedIpv4) {
|
||||
return blockedIpv4List.check(mappedIpv4, 'ipv4');
|
||||
const embeddedIpv4 = parseEmbeddedIpv4Address(normalizedAddress);
|
||||
if (embeddedIpv4) {
|
||||
return blockedIpv4List.check(embeddedIpv4, 'ipv4');
|
||||
}
|
||||
return blockedIpv6List.check(normalizedAddress, 'ipv6');
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ export class AdminGuildMembershipService {
|
||||
guildId,
|
||||
targetId,
|
||||
deleteMessageDays: data.delete_message_days,
|
||||
deleteMessageSeconds: data.delete_message_seconds,
|
||||
reason: data.reason ?? undefined,
|
||||
banDurationSeconds: data.ban_duration_seconds ?? undefined,
|
||||
skipGuildAuditLog: true,
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import {seconds} from 'itty-time';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import type {ApiContext} from '../ApiContext';
|
||||
import {createUserID, type UserID} from '../BrandedTypes';
|
||||
import {Logger} from '../Logger';
|
||||
@@ -410,6 +410,20 @@ export async function generateWebAuthnOptionsForSudo(ctx: ApiContext, userId: Us
|
||||
return options;
|
||||
}
|
||||
|
||||
const SUDO_MFA_USER_MAX_ATTEMPTS = 10;
|
||||
|
||||
async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {rateLimit} = ctx.services;
|
||||
const userLimit = await rateLimit.checkLimit({
|
||||
identifier: `sudo-mfa:user:${userId}`,
|
||||
maxAttempts: SUDO_MFA_USER_MAX_ATTEMPTS,
|
||||
windowMs: ms('15 minutes'),
|
||||
});
|
||||
if (!userLimit.allowed) {
|
||||
throw InputValidationError.fromCode('mfa_code', ValidationErrorCodes.INVALID_MFA_CODE);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySudoMfa(
|
||||
ctx: ApiContext,
|
||||
params: SudoMfaVerificationParams,
|
||||
@@ -427,7 +441,11 @@ export async function verifySudoMfa(
|
||||
case 'totp': {
|
||||
if (!code) return {success: false, error: 'TOTP code is required'};
|
||||
if (!user.totpSecret) return {success: false, error: 'TOTP is not enabled'};
|
||||
await consumeSudoMfaAttempt(ctx, userId);
|
||||
const isValid = await verifyMfaCode(ctx, {userId, mfaSecret: user.totpSecret, code, allowBackup: true});
|
||||
if (isValid) {
|
||||
await ctx.services.rateLimit.resetLimit(`sudo-mfa:user:${userId}`);
|
||||
}
|
||||
return {success: isValid, error: isValid ? undefined : 'Invalid TOTP code'};
|
||||
}
|
||||
case 'webauthn': {
|
||||
|
||||
@@ -91,6 +91,7 @@ export async function createAuthSession(
|
||||
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
|
||||
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
|
||||
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
|
||||
user = await AuthUtility.handleBanStatus(ctx, user);
|
||||
const now = new Date();
|
||||
const token = await AuthUtility.generateAuthToken(ctx);
|
||||
let clientCountry: string | null = null;
|
||||
|
||||
@@ -225,7 +225,7 @@ function resolveEmailVerified({
|
||||
if (values.includes(false)) {
|
||||
return false;
|
||||
}
|
||||
return values.length === 0 || values.includes(true);
|
||||
return values.length > 0 && values.includes(true);
|
||||
}
|
||||
|
||||
function isJsonWebKeySet(value: unknown): value is JSONWebKeySet {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
import {createAuthHarness, createUniqueEmail, createUniqueUsername, registerUser} from './AuthTestUtils';
|
||||
|
||||
async function withCaptchaEnabled<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previousEnabled = Config.captcha.enabled;
|
||||
const previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Config.captcha.enabled = previousEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerAndFlag(
|
||||
harness: ApiTestHarness,
|
||||
flags: Array<string>,
|
||||
): Promise<{email: string; password: string; userId: string}> {
|
||||
const email = createUniqueEmail('captcha-flags');
|
||||
const password = 'a-strong-password';
|
||||
const reg = await registerUser(harness, {
|
||||
email,
|
||||
username: createUniqueUsername('captchaflags'),
|
||||
global_name: 'Captcha Flags User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
if (flags.length > 0) {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${reg.user_id}/security-flags`)
|
||||
.body({set_flags: flags})
|
||||
.execute();
|
||||
}
|
||||
return {email, password, userId: reg.user_id};
|
||||
}
|
||||
|
||||
describe('Auth Captcha Bypass Flags', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('lets APP_STORE_REVIEWER accounts log in without solving a captcha', async () => {
|
||||
const account = await registerAndFlag(harness, ['APP_STORE_REVIEWER']);
|
||||
await withCaptchaEnabled(async () => {
|
||||
const resp = await createBuilderWithoutAuth<{token?: string; user_id?: string}>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.execute();
|
||||
expect(resp.token).toBeTruthy();
|
||||
expect(resp.user_id).toBe(account.userId);
|
||||
});
|
||||
});
|
||||
it('still requires a captcha for accounts without the APP_STORE_REVIEWER flag', async () => {
|
||||
const account = await registerAndFlag(harness, []);
|
||||
await withCaptchaEnabled(async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -233,11 +233,14 @@ export class AttachmentUploadService {
|
||||
const parts = await Promise.all(
|
||||
Array.from({length: partCount}, async (_, index) => {
|
||||
const partNumber = index + 1;
|
||||
const partContentLength =
|
||||
partNumber < partCount ? partSize : attachment.file_size - partSize * (partCount - 1);
|
||||
const presigned_upload_url = await this.storageService.getPresignedUploadPartURL({
|
||||
bucket,
|
||||
key: uploadKey,
|
||||
uploadId,
|
||||
partNumber,
|
||||
contentLength: partContentLength,
|
||||
});
|
||||
const upload_url = applyUploadRelayDecision({
|
||||
presignedUrl: presigned_upload_url,
|
||||
@@ -246,7 +249,7 @@ export class AttachmentUploadService {
|
||||
relayDecision: uploadRelayDecision,
|
||||
uploadId,
|
||||
partNumber,
|
||||
maxBytes: partSize,
|
||||
maxBytes: partContentLength,
|
||||
});
|
||||
return {part_number: partNumber, upload_url};
|
||||
}),
|
||||
@@ -275,7 +278,7 @@ export class AttachmentUploadService {
|
||||
if (!Config.presignedAttachmentUploadsEnabled) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
await this.getUploadPermissionAndLimit({userId, channelId});
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId});
|
||||
const bucket = Config.s3.buckets.uploads;
|
||||
return Promise.all(
|
||||
uploads.map(async ({upload_filename, upload_id}, index) => {
|
||||
@@ -305,6 +308,13 @@ export class AttachmentUploadService {
|
||||
.catch(() => undefined);
|
||||
throw InputValidationError.fromCode('parts', ValidationErrorCodes.NO_UPLOADED_PARTS_TO_FINALIZE);
|
||||
}
|
||||
const totalUploadedBytes = parts.reduce((sum, part) => sum + (part.size ?? 0), 0);
|
||||
if (totalUploadedBytes > maxFileSize) {
|
||||
await this.storageService
|
||||
.abortMultipartUpload({bucket, key: upload_filename, uploadId: upload_id})
|
||||
.catch(() => undefined);
|
||||
throw new FileSizeTooLargeError(maxFileSize);
|
||||
}
|
||||
try {
|
||||
await runAttachmentStorageOperation(() =>
|
||||
this.storageService.completeMultipartUpload({
|
||||
|
||||
@@ -174,18 +174,19 @@ export abstract class BaseChannelAuthService {
|
||||
userId,
|
||||
memberData: guildMemberResult.memberData!,
|
||||
});
|
||||
const hasPermission = async (permission: bigint): Promise<boolean> => {
|
||||
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
|
||||
};
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({
|
||||
userRepository: this.userRepository,
|
||||
guildData: guildDataResult!,
|
||||
userId,
|
||||
});
|
||||
const hasPermission = async (permission: bigint): Promise<boolean> => {
|
||||
const allowed = await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
|
||||
if (allowed) enforceGuildMfa(permission);
|
||||
return allowed;
|
||||
};
|
||||
const checkPermission = async (permission: bigint): Promise<void> => {
|
||||
const allowed = await hasPermission(permission);
|
||||
if (!allowed) throw new MissingPermissionsError();
|
||||
enforceGuildMfa(permission);
|
||||
};
|
||||
await checkPermission(Permissions.VIEW_CHANNEL);
|
||||
const parentCategory = await this.getParentCategoryContentWarningView({
|
||||
|
||||
@@ -40,9 +40,8 @@ export class DMPermissionValidator {
|
||||
if (isBugHunterBotUser(senderUser)) {
|
||||
return;
|
||||
}
|
||||
if (!senderUser.isBot && (senderUser.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER) {
|
||||
return;
|
||||
}
|
||||
const isShadowbannedSpammer =
|
||||
!senderUser.isBot && (senderUser.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER;
|
||||
const [senderBlockedTarget, targetBlockedSender, areFriends, targetSettings, senderSettings] = await Promise.all([
|
||||
this.deps.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.BLOCKED),
|
||||
this.deps.userRepository.getRelationship(recipientId, senderId, RelationshipTypes.BLOCKED),
|
||||
@@ -53,6 +52,9 @@ export class DMPermissionValidator {
|
||||
if (senderBlockedTarget || targetBlockedSender) {
|
||||
throw new CannotSendMessagesToUserError();
|
||||
}
|
||||
if (isShadowbannedSpammer) {
|
||||
return;
|
||||
}
|
||||
if (areFriends) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {assertGuildMemberCanCommunicate} from '../../utils/GuildCommunicationUtils';
|
||||
import type {IChannelRepository} from '../IChannelRepository';
|
||||
import {MessageInteractionAuthService} from './interaction/MessageInteractionAuthService';
|
||||
import {MessagePinAuthService} from './interaction/MessagePinAuthService';
|
||||
import {MessagePinService} from './interaction/MessagePinService';
|
||||
import {MessageReactionService} from './interaction/MessageReactionService';
|
||||
import {MessageReadStateService} from './interaction/MessageReadStateService';
|
||||
@@ -25,6 +26,7 @@ import type {MessagePersistenceService} from './message/MessagePersistenceServic
|
||||
|
||||
export class MessageInteractionService {
|
||||
readonly authService: MessageInteractionAuthService;
|
||||
private pinAuthService: MessagePinAuthService;
|
||||
private readStateService: MessageReadStateService;
|
||||
private pinService: MessagePinService;
|
||||
private reactionService: MessageReactionService;
|
||||
@@ -45,6 +47,12 @@ export class MessageInteractionService {
|
||||
guildRepository,
|
||||
gatewayService,
|
||||
);
|
||||
this.pinAuthService = new MessagePinAuthService(
|
||||
channelRepository,
|
||||
userRepository,
|
||||
guildRepository,
|
||||
gatewayService,
|
||||
);
|
||||
this.readStateService = new MessageReadStateService(gatewayService);
|
||||
this.pinService = new MessagePinService(
|
||||
gatewayService,
|
||||
@@ -85,7 +93,7 @@ export class MessageInteractionService {
|
||||
items: Array<ChannelPinResponse>;
|
||||
has_more: boolean;
|
||||
}> {
|
||||
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
|
||||
const authChannel = await this.pinAuthService.getChannelAuthenticated({userId, channelId});
|
||||
return this.pinService.getChannelPins({authChannel, userId, requestCache, beforeTimestamp, limit});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
|
||||
import type {MessageID, UserID} from '../../../BrandedTypes';
|
||||
import {Config} from '../../../Config';
|
||||
import type {IPurgeQueue} from '../../../infrastructure/BunnyPurgeQueue';
|
||||
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||
import type {IStorageService} from '../../../infrastructure/IStorageService';
|
||||
@@ -13,7 +12,7 @@ import type {Message} from '../../../models/Message';
|
||||
import {mapChannelToResponse} from '../../ChannelMappers';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import {dispatchMessageCreateBroadcast} from '../message/MessageGatewayDispatch';
|
||||
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '../message/MessageHelpers';
|
||||
import {purgeMessageAttachments} from '../message/MessageHelpers';
|
||||
|
||||
export class ChannelUtilsService {
|
||||
constructor(
|
||||
@@ -44,20 +43,7 @@ export class ChannelUtilsService {
|
||||
}
|
||||
|
||||
private async purgeMessageAttachments(message: Message): Promise<void> {
|
||||
const cdnUrls: Array<string> = [];
|
||||
await Promise.all(
|
||||
message.attachments.map(async (attachment) => {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
|
||||
if (Config.bunny.purgeEnabled) {
|
||||
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
|
||||
cdnUrls.push(cdnUrl);
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
|
||||
await this.purgeQueue.addUrls(cdnUrls);
|
||||
}
|
||||
await purgeMessageAttachments(message, this.storageService, this.purgeQueue);
|
||||
}
|
||||
|
||||
async dispatchChannelUpdate({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {BaseChannelAuthService, type ChannelAuthOptions} from '../BaseChannelAuthService';
|
||||
|
||||
export class MessagePinAuthService extends BaseChannelAuthService {
|
||||
protected readonly options: ChannelAuthOptions = {
|
||||
errorOnMissingGuild: 'unknown_channel',
|
||||
validateNsfw: true,
|
||||
};
|
||||
}
|
||||
@@ -116,6 +116,16 @@ export class AttachmentProcessingService {
|
||||
);
|
||||
const hasVirusDetected = results.some((result) => result.hasVirusDetected);
|
||||
if (hasVirusDetected) {
|
||||
await Promise.all(
|
||||
results.map(async (result) => {
|
||||
if (result.sourceLocalPath) {
|
||||
await fs.promises.unlink(result.sourceLocalPath).catch(() => undefined);
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const result of results) {
|
||||
this.deleteUploadObject(result.copyOperation.sourceBucket, result.copyOperation.sourceKey);
|
||||
}
|
||||
return {attachments: [], hasVirusDetected: true};
|
||||
}
|
||||
const copyResults = await mapWithConcurrency(results, ATTACHMENT_PROCESSING_CONCURRENCY, (result) =>
|
||||
|
||||
@@ -225,14 +225,14 @@ export class MessageDeleteService {
|
||||
async deleteUserMessagesInGuild({
|
||||
userId,
|
||||
guildId,
|
||||
days,
|
||||
seconds,
|
||||
}: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
days: number;
|
||||
seconds: number;
|
||||
}): Promise<void> {
|
||||
const channels = await this.deps.channelRepository.channelData.listGuildChannels(guildId);
|
||||
const cutoffTimestamp = Date.now() - days * ms('1 day');
|
||||
const cutoffTimestamp = Date.now() - seconds * ms('1 second');
|
||||
const cutoffSnowflake = createMessageID(createSnowflakeFromTimestamp(cutoffTimestamp));
|
||||
await Promise.all(
|
||||
channels.map(async (channel: Channel) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {LimitConfigService} from '../../../limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '../../../limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder';
|
||||
import {Attachment} from '../../../models/Attachment';
|
||||
import type {Embed} from '../../../models/Embed';
|
||||
import type {Message} from '../../../models/Message';
|
||||
import {MessageSnapshot as MessageSnapshotModel} from '../../../models/MessageSnapshot';
|
||||
import type {User} from '../../../models/User';
|
||||
@@ -307,22 +308,60 @@ export async function createMessageSnapshotsForForward(
|
||||
return [new MessageSnapshotModel(snapshotData)];
|
||||
}
|
||||
|
||||
function collectEmbedReferencedAttachmentCdnKeys(message: Message): Array<string> {
|
||||
const mediaPrefix = `${Config.endpoints.media}/`;
|
||||
const keys = new Set<string>();
|
||||
const consider = (url: string | null | undefined): void => {
|
||||
if (!url || !url.startsWith(mediaPrefix)) {
|
||||
return;
|
||||
}
|
||||
const key = url.slice(mediaPrefix.length);
|
||||
if (key.startsWith('attachments/')) {
|
||||
keys.add(key);
|
||||
}
|
||||
};
|
||||
const scanEmbeds = (embeds: Array<Embed>): void => {
|
||||
for (const embed of embeds) {
|
||||
consider(embed.image?.url);
|
||||
consider(embed.thumbnail?.url);
|
||||
consider(embed.video?.url);
|
||||
consider(embed.audio?.url);
|
||||
}
|
||||
};
|
||||
scanEmbeds(message.embeds);
|
||||
for (const snapshot of message.messageSnapshots) {
|
||||
scanEmbeds(snapshot.embeds);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export async function purgeMessageAttachments(
|
||||
message: Message,
|
||||
storageService: IStorageService,
|
||||
purgeQueue: IPurgeQueue,
|
||||
): Promise<void> {
|
||||
const cdnKeys = new Set<string>();
|
||||
const cdnUrls: Array<string> = [];
|
||||
await Promise.all(
|
||||
message.attachments.map(async (attachment) => {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
await storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
|
||||
if (Config.bunny.purgeEnabled) {
|
||||
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
|
||||
cdnUrls.push(cdnUrl);
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const attachment of collectMessageAttachments(message)) {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
if (cdnKeys.has(cdnKey)) {
|
||||
continue;
|
||||
}
|
||||
cdnKeys.add(cdnKey);
|
||||
if (Config.bunny.purgeEnabled) {
|
||||
cdnUrls.push(makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename));
|
||||
}
|
||||
}
|
||||
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message)) {
|
||||
if (cdnKeys.has(embedKey)) {
|
||||
continue;
|
||||
}
|
||||
cdnKeys.add(embedKey);
|
||||
if (Config.bunny.purgeEnabled) {
|
||||
cdnUrls.push(`${Config.endpoints.media}/${embedKey}`);
|
||||
}
|
||||
}
|
||||
await Promise.all([...cdnKeys].map((cdnKey) => storageService.deleteObject(Config.s3.buckets.cdn, cdnKey)));
|
||||
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
|
||||
await purgeQueue.addUrls(cdnUrls);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {IFavoriteMemeRepository} from '../../../favorite_meme/IFavoriteMeme
|
||||
import type {IMediaService} from '../../../infrastructure/IMediaService';
|
||||
import type {ISnowflakeService} from '../../../infrastructure/ISnowflakeService';
|
||||
import type {IStorageService} from '../../../infrastructure/IStorageService';
|
||||
import {Logger} from '../../../Logger';
|
||||
import type {FavoriteMeme} from '../../../models/FavoriteMeme';
|
||||
import type {Message} from '../../../models/Message';
|
||||
import type {User} from '../../../models/User';
|
||||
@@ -90,8 +91,21 @@ export class MessageOperationsHelpers {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const needsAnimationProbe =
|
||||
!favoriteMeme.isGifv &&
|
||||
favoriteMeme.contentType !== 'image/gif' &&
|
||||
favoriteMeme.contentType !== 'image/apng' &&
|
||||
ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType);
|
||||
const metadata =
|
||||
needsAnimationProbe || favoriteMeme.placeholder == null
|
||||
? await this.probeFavoriteMemeMetadata(favoriteMeme)
|
||||
: null;
|
||||
const placeholder = favoriteMeme.placeholder ?? metadata?.placeholder ?? null;
|
||||
if (placeholder != null && favoriteMeme.placeholder == null) {
|
||||
await this.repairFavoriteMemePlaceholder(favoriteMeme, placeholder);
|
||||
}
|
||||
let flags = 0;
|
||||
if (await this.isFavoriteMemeAnimated(favoriteMeme)) {
|
||||
if (this.isFavoriteMemeAnimated(favoriteMeme, metadata)) {
|
||||
flags |= MessageAttachmentFlags.IS_ANIMATED;
|
||||
}
|
||||
return {
|
||||
@@ -104,7 +118,7 @@ export class MessageOperationsHelpers {
|
||||
height: favoriteMeme.height,
|
||||
content_type: favoriteMeme.contentType,
|
||||
content_hash: favoriteMeme.contentHash,
|
||||
placeholder: null,
|
||||
placeholder,
|
||||
flags,
|
||||
duration: favoriteMeme.duration,
|
||||
nsfw: null,
|
||||
@@ -112,20 +126,31 @@ export class MessageOperationsHelpers {
|
||||
};
|
||||
}
|
||||
|
||||
private async isFavoriteMemeAnimated(favoriteMeme: FavoriteMeme): Promise<boolean> {
|
||||
if (favoriteMeme.isGifv) return true;
|
||||
if (favoriteMeme.contentType === 'image/gif' || favoriteMeme.contentType === 'image/apng') return true;
|
||||
if (!ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType)) return false;
|
||||
private async probeFavoriteMemeMetadata(favoriteMeme: FavoriteMeme) {
|
||||
try {
|
||||
const metadata = await this.deps.mediaService.getMetadata({
|
||||
return await this.deps.mediaService.getMetadata({
|
||||
type: 's3',
|
||||
bucket: Config.s3.buckets.cdn,
|
||||
key: favoriteMeme.storageKey,
|
||||
nsfw: 'allow',
|
||||
});
|
||||
return metadata?.animated === true;
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async repairFavoriteMemePlaceholder(favoriteMeme: FavoriteMeme, placeholder: string): Promise<void> {
|
||||
try {
|
||||
await this.deps.favoriteMemeRepository.updatePlaceholder(favoriteMeme.userId, favoriteMeme.id, placeholder);
|
||||
} catch (error) {
|
||||
Logger.warn({error, memeId: favoriteMeme.id.toString()}, 'Failed to backfill favorite meme placeholder');
|
||||
}
|
||||
}
|
||||
|
||||
private isFavoriteMemeAnimated(favoriteMeme: FavoriteMeme, metadata: {animated?: boolean | null} | null): boolean {
|
||||
if (favoriteMeme.isGifv) return true;
|
||||
if (favoriteMeme.contentType === 'image/gif' || favoriteMeme.contentType === 'image/apng') return true;
|
||||
if (!ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType)) return false;
|
||||
return metadata?.animated === true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,9 +248,10 @@ export class MessageValidationService {
|
||||
}
|
||||
const isAuthor = message.authorId === userId;
|
||||
if (!guild) return isAuthor;
|
||||
if (isAuthor) return true;
|
||||
const canManageMessages =
|
||||
(await hasPermission(Permissions.SEND_MESSAGES)) && (await hasPermission(Permissions.MANAGE_MESSAGES));
|
||||
return isAuthor || canManageMessages;
|
||||
return canManageMessages;
|
||||
}
|
||||
|
||||
private validateVoiceMessageConstraints(
|
||||
|
||||
@@ -30,6 +30,7 @@ import {Config} from '../Config';
|
||||
import type {IChannelRepository} from '../channel/IChannelRepository';
|
||||
import type {AttachmentUploadTraceRepository} from '../channel/repositories/message/AttachmentUploadTraceRepository';
|
||||
import {
|
||||
collectMessageAttachments,
|
||||
makeAttachmentCdnKey,
|
||||
makeAttachmentCdnUrl,
|
||||
purgeMessageAttachments,
|
||||
@@ -43,6 +44,9 @@ import type {IStorageService} from '../infrastructure/IStorageService';
|
||||
import type {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDeletionQueueService';
|
||||
import type {UserCacheService} from '../infrastructure/UserCacheService';
|
||||
import {Logger} from '../Logger';
|
||||
import type {Embed} from '../models/Embed';
|
||||
import type {EmbedMedia} from '../models/EmbedMedia';
|
||||
import type {Message} from '../models/Message';
|
||||
import type {User} from '../models/User';
|
||||
import type {IARMessageContext, IARSubmission} from '../report/IReportRepository';
|
||||
import type {ReportRepository} from '../report/ReportRepository';
|
||||
@@ -471,18 +475,34 @@ export class NcmecSubmissionService {
|
||||
|
||||
private async resolveAttachment(input: SubmitAttachmentToNcmecInput): Promise<ResolvedAttachment> {
|
||||
const liveMessage = await this.deps.channelRepository.getMessage(input.channelId, input.messageId);
|
||||
const liveAttachment = liveMessage?.attachments.find(
|
||||
(attachment) => attachment.id === input.attachmentId && attachment.filename === input.filename,
|
||||
);
|
||||
if (liveMessage?.authorId && liveAttachment) {
|
||||
return this.buildAttachmentContext(input, {
|
||||
contentType: liveAttachment.contentType,
|
||||
reportedAt: snowflakeToDate(input.messageId),
|
||||
userId: liveMessage.authorId,
|
||||
sourceReportId: input.sourceReportId ?? null,
|
||||
bucket: Config.s3.buckets.cdn,
|
||||
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename),
|
||||
});
|
||||
if (liveMessage?.authorId) {
|
||||
const liveAttachment = collectMessageAttachments(liveMessage).find(
|
||||
(attachment) => attachment.id === input.attachmentId && attachment.filename === input.filename,
|
||||
);
|
||||
if (liveAttachment) {
|
||||
return this.buildAttachmentContext(input, {
|
||||
contentType: liveAttachment.contentType,
|
||||
reportedAt: snowflakeToDate(input.messageId),
|
||||
userId: liveMessage.authorId,
|
||||
sourceReportId: input.sourceReportId ?? null,
|
||||
bucket: Config.s3.buckets.cdn,
|
||||
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename),
|
||||
});
|
||||
}
|
||||
const embedMedia = findEmbedReferencedAttachmentMedia(
|
||||
liveMessage,
|
||||
makeAttachmentCdnUrl(input.channelId, input.attachmentId, input.filename),
|
||||
);
|
||||
if (embedMedia) {
|
||||
return this.buildAttachmentContext(input, {
|
||||
contentType: embedMedia.contentType,
|
||||
reportedAt: snowflakeToDate(input.messageId),
|
||||
userId: liveMessage.authorId,
|
||||
sourceReportId: input.sourceReportId ?? null,
|
||||
bucket: Config.s3.buckets.cdn,
|
||||
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!input.sourceReportId) {
|
||||
throw new UnknownMessageError();
|
||||
@@ -939,6 +959,30 @@ function buildAuditMetadata(args: {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function findEmbedReferencedAttachmentMedia(message: Message, targetCdnUrl: string): EmbedMedia | null {
|
||||
const scan = (embeds: Array<Embed>): EmbedMedia | null => {
|
||||
for (const embed of embeds) {
|
||||
for (const media of [embed.image, embed.thumbnail, embed.video, embed.audio]) {
|
||||
if (media?.url === targetCdnUrl) {
|
||||
return media;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const direct = scan(message.embeds);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
for (const snapshot of message.messageSnapshots) {
|
||||
const found = scan(snapshot.embeds);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findAttachmentInReport(
|
||||
report: IARSubmission,
|
||||
channelId: ChannelID,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import cassandra from 'cassandra-driver';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {CassandraParams, KvQueryMeta, KvTableSpec, WhereExpr} from './CassandraTypes';
|
||||
import {buildCandidatePlan, keyFromColumns, matchesWhere} from './PostgresKvQueryExecutor';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type AnyMeta = KvQueryMeta<Row>;
|
||||
|
||||
const Spec: KvTableSpec<Row> = {
|
||||
name: 'fuzz_two',
|
||||
columns: ['k', 'c', 'v'],
|
||||
primaryKey: ['k', 'c'],
|
||||
partitionKey: ['k', 'c'],
|
||||
};
|
||||
|
||||
const SpecOne: KvTableSpec<Row> = {
|
||||
name: 'fuzz_one',
|
||||
columns: ['k', 'v'],
|
||||
primaryKey: ['k'],
|
||||
partitionKey: ['k'],
|
||||
};
|
||||
|
||||
const VALUES: Array<[string, unknown]> = [
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['zero', 0],
|
||||
['neg-zero', -0],
|
||||
['one-num', 1],
|
||||
['one-big', 1n],
|
||||
['one-str', '1'],
|
||||
['empty-str', ''],
|
||||
['true', true],
|
||||
['false', false],
|
||||
['date-epoch', new Date(0)],
|
||||
['date-epoch-2', new Date(0)],
|
||||
['buf-a', Buffer.from('a')],
|
||||
['buf-a-2', Buffer.from('a')],
|
||||
['localdate', cassandra.types.LocalDate.fromString('2020-01-01')],
|
||||
['localdate-str', '2020-01-01'],
|
||||
];
|
||||
|
||||
function rowKeyMatchesPlan(plan: ReturnType<typeof buildCandidatePlan>, rowKey: string): boolean {
|
||||
const c = plan.candidates;
|
||||
const buf = Buffer.from(rowKey, 'utf8');
|
||||
switch (c.kind) {
|
||||
case 'none':
|
||||
return false;
|
||||
case 'scan':
|
||||
return true;
|
||||
case 'rowKeys':
|
||||
return c.rowKeys.includes(rowKey);
|
||||
case 'range':
|
||||
return (
|
||||
Buffer.compare(buf, Buffer.from(c.lowerBound, 'utf8')) >= 0 &&
|
||||
Buffer.compare(buf, Buffer.from(c.upperBound, 'utf8')) < 0
|
||||
);
|
||||
case 'ranges':
|
||||
return c.lowerBounds.some(
|
||||
(lower, i) =>
|
||||
Buffer.compare(buf, Buffer.from(lower, 'utf8')) >= 0 &&
|
||||
Buffer.compare(buf, Buffer.from(c.upperBounds[i]!, 'utf8')) < 0,
|
||||
);
|
||||
case 'partitionKeys':
|
||||
return c.partitionKeys.includes(rowKey);
|
||||
}
|
||||
}
|
||||
|
||||
describe('postgres kv pushdown exactness', () => {
|
||||
it('never excludes a row the javascript filter would keep, and is equal when exact', () => {
|
||||
const narrowing: Array<string> = [];
|
||||
const widening: Array<string> = [];
|
||||
const meta = {
|
||||
action: 'count',
|
||||
table: Spec,
|
||||
where: [{kind: 'eq', col: 'k', param: 'k'}] as Array<WhereExpr<Row>>,
|
||||
} as AnyMeta;
|
||||
for (const [paramName, paramValue] of VALUES) {
|
||||
const params = {k: paramValue} as CassandraParams;
|
||||
const plan = buildCandidatePlan(meta, params);
|
||||
for (const [storedName, storedValue] of VALUES) {
|
||||
const row: Row = {k: storedValue, c: 'x', v: 1};
|
||||
const rowKey = keyFromColumns(Spec.primaryKey as Array<string>, row);
|
||||
const js = matchesWhere(row, meta.where as Array<WhereExpr<Row>>, params);
|
||||
const sql = rowKeyMatchesPlan(plan, rowKey);
|
||||
const label = `param=${paramName} stored=${storedName} exact=${plan.exact} plan=${plan.candidates.kind}`;
|
||||
if (js && !sql) narrowing.push(label);
|
||||
if (plan.exact && sql && !js) widening.push(label);
|
||||
}
|
||||
}
|
||||
expect({narrowing, widening}).toEqual({narrowing: [], widening: []});
|
||||
});
|
||||
|
||||
it('single-column primary keys behave the same', () => {
|
||||
const narrowing: Array<string> = [];
|
||||
const widening: Array<string> = [];
|
||||
const meta = {
|
||||
action: 'count',
|
||||
table: SpecOne,
|
||||
where: [{kind: 'eq', col: 'k', param: 'k'}] as Array<WhereExpr<Row>>,
|
||||
} as AnyMeta;
|
||||
for (const [paramName, paramValue] of VALUES) {
|
||||
const params = {k: paramValue} as CassandraParams;
|
||||
const plan = buildCandidatePlan(meta, params);
|
||||
for (const [storedName, storedValue] of VALUES) {
|
||||
const row: Row = {k: storedValue, v: 1};
|
||||
const rowKey = keyFromColumns(SpecOne.primaryKey as Array<string>, row);
|
||||
const js = matchesWhere(row, meta.where as Array<WhereExpr<Row>>, params);
|
||||
const sql = rowKeyMatchesPlan(plan, rowKey);
|
||||
const label = `param=${paramName} stored=${storedName} exact=${plan.exact} plan=${plan.candidates.kind}`;
|
||||
if (js && !sql) narrowing.push(label);
|
||||
if (plan.exact && sql && !js) widening.push(label);
|
||||
}
|
||||
}
|
||||
expect({narrowing, widening}).toEqual({narrowing: [], widening: []});
|
||||
});
|
||||
|
||||
it('IN lists behave the same', () => {
|
||||
const narrowing: Array<string> = [];
|
||||
const widening: Array<string> = [];
|
||||
const meta = {
|
||||
action: 'count',
|
||||
table: SpecOne,
|
||||
where: [{kind: 'in', col: 'k', param: 'ks'}] as Array<WhereExpr<Row>>,
|
||||
} as AnyMeta;
|
||||
for (let i = 0; i < VALUES.length; i += 1) {
|
||||
for (let j = i; j < VALUES.length; j += 1) {
|
||||
const params = {ks: [VALUES[i]![1], VALUES[j]![1]]} as CassandraParams;
|
||||
const plan = buildCandidatePlan(meta, params);
|
||||
for (const [storedName, storedValue] of VALUES) {
|
||||
const row: Row = {k: storedValue, v: 1};
|
||||
const rowKey = keyFromColumns(SpecOne.primaryKey as Array<string>, row);
|
||||
const js = matchesWhere(row, meta.where as Array<WhereExpr<Row>>, params);
|
||||
const sql = rowKeyMatchesPlan(plan, rowKey);
|
||||
const label = `in=[${VALUES[i]![0]},${VALUES[j]![0]}] stored=${storedName} exact=${plan.exact} plan=${plan.candidates.kind}`;
|
||||
if (js && !sql) narrowing.push(label);
|
||||
if (plan.exact && sql && !js) widening.push(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect({narrowing: narrowing.slice(0, 10), widening: widening.slice(0, 10)}).toEqual({narrowing: [], widening: []});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
import {defineTable} from './CassandraTableDsl';
|
||||
import type {KvQueryMeta} from './CassandraTypes';
|
||||
|
||||
vi.mock('../Logger', () => ({
|
||||
Logger: new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('Logger has not been initialized. Call initializeLogger() first.');
|
||||
},
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const Probe = defineTable<{k: string; v: string}, 'k'>({
|
||||
name: 'kv_logger_probe',
|
||||
columns: ['k', 'v'],
|
||||
primaryKey: ['k'],
|
||||
});
|
||||
|
||||
const client = {
|
||||
async query() {
|
||||
return {rows: [], rowCount: 0};
|
||||
},
|
||||
async connect() {},
|
||||
async shutdown() {},
|
||||
isConnected() {
|
||||
return true;
|
||||
},
|
||||
async transaction(fn: (db: unknown) => Promise<unknown>) {
|
||||
return fn(client);
|
||||
},
|
||||
kvTable() {
|
||||
return 'kv_logger_probe_table';
|
||||
},
|
||||
} as never;
|
||||
|
||||
describe('PostgresKvQueryExecutor logging safety', () => {
|
||||
it('runs full scan queries in a process whose logger was never initialized', async () => {
|
||||
const {PostgresKvQueryExecutor} = await import('./PostgresKvQueryExecutor');
|
||||
const executor = new PostgresKvQueryExecutor(client);
|
||||
for (const action of ['select', 'count', 'delete'] as const) {
|
||||
const meta = {action, table: Probe, where: [], columns: ['k', 'v']} as unknown as KvQueryMeta<Row>;
|
||||
await expect(executor.executeQuery({cql: `__probe_${action}`, params: {}, kvMeta: meta})).resolves.toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {execFileSync, spawnSync} from 'node:child_process';
|
||||
import {createServer} from 'node:net';
|
||||
import {
|
||||
getDefaultPostgresClient,
|
||||
type IPostgresClient,
|
||||
initPostgres,
|
||||
type PostgresQueryable,
|
||||
shutdownPostgres,
|
||||
} from '@pkgs/postgres/src/Client';
|
||||
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||
import {LegacyPostgresKvQueryExecutor} from './__testref__/LegacyPostgresKvQueryExecutor';
|
||||
import type {CassandraParams, KvQueryMeta, KvTableSpec, WhereExpr} from './CassandraTypes';
|
||||
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from './PostgresKvQueryExecutor';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type AnyMeta = KvQueryMeta<Row>;
|
||||
|
||||
const LEGACY_TABLE = 'kvns_legacy';
|
||||
const NEXT_TABLE = 'kvns_next';
|
||||
const CONTAINER = `fluxer-kvns-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
||||
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
||||
|
||||
class TableClient implements IPostgresClient {
|
||||
constructor(
|
||||
private readonly inner: IPostgresClient,
|
||||
private readonly table: string,
|
||||
) {}
|
||||
async query<T extends Record<string, unknown>>(text: string, values: Array<unknown> = []) {
|
||||
return (await this.inner.query(text, values)) as unknown as Awaited<ReturnType<IPostgresClient['query']>> & {
|
||||
rows: Array<T>;
|
||||
};
|
||||
}
|
||||
async connect(): Promise<void> {
|
||||
await this.inner.connect();
|
||||
}
|
||||
async shutdown(): Promise<void> {}
|
||||
isConnected(): boolean {
|
||||
return this.inner.isConnected();
|
||||
}
|
||||
async transaction<T>(fn: (client: PostgresQueryable) => Promise<T>): Promise<T> {
|
||||
return this.inner.transaction(fn);
|
||||
}
|
||||
kvTable(): string {
|
||||
return this.table;
|
||||
}
|
||||
}
|
||||
|
||||
const Parent: KvTableSpec<Row> = {
|
||||
name: 'ns_parent',
|
||||
columns: ['user_id', 'channel_id', 'note', 'blob_'],
|
||||
primaryKey: ['user_id', 'channel_id'],
|
||||
partitionKey: ['user_id', 'channel_id'],
|
||||
};
|
||||
|
||||
const Single: KvTableSpec<Row> = {
|
||||
name: 'ns_single',
|
||||
columns: ['token_', 'note'],
|
||||
primaryKey: ['token_'],
|
||||
partitionKey: ['token_'],
|
||||
};
|
||||
|
||||
function eq(col: string, param: string): WhereExpr<Row> {
|
||||
return {kind: 'eq', col, param} as WhereExpr<Row>;
|
||||
}
|
||||
function inClause(col: string, param: string): WhereExpr<Row> {
|
||||
return {kind: 'in', col, param} as WhereExpr<Row>;
|
||||
}
|
||||
|
||||
describe.skipIf(!dockerAvailable)('postgres kv non-select drift', () => {
|
||||
let raw: IPostgresClient;
|
||||
let legacy: LegacyPostgresKvQueryExecutor;
|
||||
let next: PostgresKvQueryExecutor;
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (typeof address === 'string' || address === null) {
|
||||
reject(new Error('no port'));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function dump(kv: string, tableName: string): Promise<string> {
|
||||
const result = await raw.query<{row_key: string; partition_key: string; row_data: unknown; expires_at: Date}>(
|
||||
`SELECT row_key, partition_key, row_data, expires_at FROM ${kv} WHERE table_name = $1 ORDER BY row_key COLLATE "C"`,
|
||||
[tableName],
|
||||
);
|
||||
return JSON.stringify(
|
||||
result.rows.map((r) => [
|
||||
r.row_key,
|
||||
r.partition_key,
|
||||
r.row_data,
|
||||
r.expires_at === null ? null : r.expires_at instanceof Date ? 'ts' : String(r.expires_at),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function reset(spec: KvTableSpec<Row>, rows: ReadonlyArray<Row>): Promise<void> {
|
||||
for (const kv of [LEGACY_TABLE, NEXT_TABLE]) {
|
||||
await raw.query(`DELETE FROM ${kv} WHERE table_name = $1`, [spec.name]);
|
||||
}
|
||||
const meta = {action: 'upsert', table: spec} as AnyMeta;
|
||||
for (const row of rows) {
|
||||
await legacy.executeQuery({cql: '__seed__', params: row as CassandraParams, kvMeta: meta});
|
||||
await next.executeQuery({cql: '__seed__', params: row as CassandraParams, kvMeta: meta});
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const port = await freePort();
|
||||
execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
CONTAINER,
|
||||
'-e',
|
||||
'POSTGRES_USER=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_PASSWORD=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_DB=fluxer',
|
||||
'-p',
|
||||
`127.0.0.1:${port}:5432`,
|
||||
'postgres:16-alpine',
|
||||
'-c',
|
||||
'fsync=off',
|
||||
],
|
||||
{stdio: 'ignore'},
|
||||
);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
|
||||
await sleep(500);
|
||||
const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (probe.status !== 0) continue;
|
||||
try {
|
||||
await initPostgres({url: `postgres://fluxer:[email protected]:${port}/fluxer`, maxConnections: 8});
|
||||
await getDefaultPostgresClient().query('SELECT 1');
|
||||
ready = true;
|
||||
} catch {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (!ready) throw new Error('postgres never came up');
|
||||
raw = getDefaultPostgresClient();
|
||||
const legacyClient = new TableClient(raw, LEGACY_TABLE);
|
||||
const nextClient = new TableClient(raw, NEXT_TABLE);
|
||||
await ensurePostgresKvSchema(legacyClient);
|
||||
await ensurePostgresKvSchema(nextClient);
|
||||
legacy = new LegacyPostgresKvQueryExecutor(legacyClient);
|
||||
next = new PostgresKvQueryExecutor(nextClient);
|
||||
}, 900_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
|
||||
const seedRows: Array<Row> = [
|
||||
{user_id: 1n, channel_id: 10n, note: 'a', blob_: null},
|
||||
{user_id: 1n, channel_id: 11n, note: 'b', blob_: null},
|
||||
{user_id: 2n, channel_id: 10n, note: 'c', blob_: null},
|
||||
];
|
||||
|
||||
it('upsert / insert / ifNotExists behave identically', async () => {
|
||||
await reset(Parent, seedRows);
|
||||
const insMeta = {action: 'upsert', table: Parent, ifNotExists: true} as AnyMeta;
|
||||
const fresh = {user_id: 3n, channel_id: 30n, note: 'z', blob_: null};
|
||||
const l1 = await legacy.executeQuery({cql: '__i__', params: fresh as CassandraParams, kvMeta: insMeta});
|
||||
const n1 = await next.executeQuery({cql: '__i__', params: fresh as CassandraParams, kvMeta: insMeta});
|
||||
expect(n1).toEqual(l1);
|
||||
const l2 = await legacy.executeQuery({cql: '__i__', params: fresh as CassandraParams, kvMeta: insMeta});
|
||||
const n2 = await next.executeQuery({cql: '__i__', params: fresh as CassandraParams, kvMeta: insMeta});
|
||||
expect(n2).toEqual(l2);
|
||||
expect(await dump(NEXT_TABLE, Parent.name)).toBe(await dump(LEGACY_TABLE, Parent.name));
|
||||
});
|
||||
|
||||
it('patch with and without ttl leaves identical state', async () => {
|
||||
await reset(Parent, seedRows);
|
||||
const patchMeta = {
|
||||
action: 'patch',
|
||||
table: Parent,
|
||||
patchKeys: ['note'],
|
||||
pkColumns: ['user_id', 'channel_id'],
|
||||
} as unknown as AnyMeta;
|
||||
const p = {user_id: 1n, channel_id: 10n, note: 'patched'} as CassandraParams;
|
||||
await legacy.executeQuery({cql: '__p__', params: p, kvMeta: patchMeta});
|
||||
await next.executeQuery({cql: '__p__', params: p, kvMeta: patchMeta});
|
||||
const ttlMeta = {...patchMeta, ttlParamName: 'ttl_'} as unknown as AnyMeta;
|
||||
const pt = {user_id: 2n, channel_id: 10n, note: 'ttl', ttl_: 600} as CassandraParams;
|
||||
await legacy.executeQuery({cql: '__pt__', params: pt, kvMeta: ttlMeta});
|
||||
await next.executeQuery({cql: '__pt__', params: pt, kvMeta: ttlMeta});
|
||||
expect(await dump(NEXT_TABLE, Parent.name)).toBe(await dump(LEGACY_TABLE, Parent.name));
|
||||
});
|
||||
|
||||
it('count returns the same value and the same javascript type', async () => {
|
||||
await reset(Parent, seedRows);
|
||||
await raw.query(
|
||||
`UPDATE ${NEXT_TABLE} SET expires_at = now() - interval '1 hour' WHERE table_name = $1 AND row_key LIKE '%11%'`,
|
||||
[Parent.name],
|
||||
);
|
||||
await raw.query(
|
||||
`UPDATE ${LEGACY_TABLE} SET expires_at = now() - interval '1 hour' WHERE table_name = $1 AND row_key LIKE '%11%'`,
|
||||
[Parent.name],
|
||||
);
|
||||
const meta = {action: 'count', table: Parent, where: [eq('user_id', 'user_id')]} as AnyMeta;
|
||||
const l = await legacy.executeQuery<{count: unknown}>({cql: '__c__', params: {user_id: 1n}, kvMeta: meta});
|
||||
const n = await next.executeQuery<{count: unknown}>({cql: '__c__', params: {user_id: 1n}, kvMeta: meta});
|
||||
expect(n).toEqual(l);
|
||||
expect(typeof (n[0] as {count: unknown}).count).toBe(typeof (l[0] as {count: unknown}).count);
|
||||
});
|
||||
|
||||
it('count over an empty logical table matches', async () => {
|
||||
const meta = {action: 'count', table: Single, where: []} as AnyMeta;
|
||||
for (const kv of [LEGACY_TABLE, NEXT_TABLE]) {
|
||||
await raw.query(`DELETE FROM ${kv} WHERE table_name = $1`, [Single.name]);
|
||||
}
|
||||
const l = await legacy.executeQuery({cql: '__ce__', params: {}, kvMeta: meta});
|
||||
const n = await next.executeQuery({cql: '__ce__', params: {}, kvMeta: meta});
|
||||
expect(n).toEqual(l);
|
||||
});
|
||||
|
||||
it('count with a string bound to an IN parameter matches legacy', async () => {
|
||||
await reset(Single, [
|
||||
{token_: 'a', note: '1'},
|
||||
{token_: 'b', note: '2'},
|
||||
]);
|
||||
const meta = {action: 'count', table: Single, where: [inClause('token_', 'tokens')]} as AnyMeta;
|
||||
let legacyOut: unknown;
|
||||
let nextOut: unknown;
|
||||
try {
|
||||
legacyOut = await legacy.executeQuery({cql: '__cs__', params: {tokens: 'ab'} as CassandraParams, kvMeta: meta});
|
||||
} catch (error) {
|
||||
legacyOut = `throw:${(error as Error).constructor.name}`;
|
||||
}
|
||||
try {
|
||||
nextOut = await next.executeQuery({cql: '__cs__', params: {tokens: 'ab'} as CassandraParams, kvMeta: meta});
|
||||
} catch (error) {
|
||||
nextOut = `throw:${(error as Error).constructor.name}`;
|
||||
}
|
||||
expect(nextOut).toEqual(legacyOut);
|
||||
});
|
||||
|
||||
it('rejects malformed page tokens the way legacy does', async () => {
|
||||
await reset(Single, [
|
||||
{token_: 'a', note: '1'},
|
||||
{token_: 'b', note: '2'},
|
||||
{token_: 'c', note: '3'},
|
||||
]);
|
||||
const meta = {action: 'select', table: Single, where: [], columns: Single.columns} as AnyMeta;
|
||||
const tokens = ['eyJmb28iOjF9', 'e30', 'MTIz', 'InN0ciI', 'eyJvZmZzZXQiOi0xfQ', 'eyJvZmZzZXQiOjF9'];
|
||||
const legacyOut: Array<string> = [];
|
||||
const nextOut: Array<string> = [];
|
||||
for (const token of tokens) {
|
||||
for (const [exec, sink] of [
|
||||
[legacy, legacyOut],
|
||||
[next, nextOut],
|
||||
] as const) {
|
||||
try {
|
||||
const page = await exec.executePagedQuery(
|
||||
{cql: '__pg__', params: {}, kvMeta: meta},
|
||||
{pageSize: 1, pageState: token},
|
||||
);
|
||||
sink.push(`${token} -> rows=${page.rows.length}`);
|
||||
} catch (error) {
|
||||
sink.push(`${token} -> throw:${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(nextOut).toEqual(legacyOut);
|
||||
});
|
||||
|
||||
it('delete inside an atomic batch rolls back identically', async () => {
|
||||
await reset(Parent, seedRows);
|
||||
const delMeta = {action: 'delete', table: Parent, where: [eq('user_id', 'user_id')]} as AnyMeta;
|
||||
for (const [exec, kv] of [
|
||||
[legacy, LEGACY_TABLE],
|
||||
[next, NEXT_TABLE],
|
||||
] as const) {
|
||||
await expect(
|
||||
exec.executeBatch([
|
||||
{query: '__d__', params: {user_id: 1n}, meta: delMeta as KvQueryMeta},
|
||||
{query: '__boom__', params: {}, meta: {action: 'bogus'} as unknown as KvQueryMeta},
|
||||
]),
|
||||
).rejects.toThrow();
|
||||
void kv;
|
||||
}
|
||||
expect(await dump(NEXT_TABLE, Parent.name)).toBe(await dump(LEGACY_TABLE, Parent.name));
|
||||
});
|
||||
|
||||
it('delete of an expired-but-unpruned row matches legacy', async () => {
|
||||
await reset(Parent, seedRows);
|
||||
for (const kv of [LEGACY_TABLE, NEXT_TABLE]) {
|
||||
await raw.query(`UPDATE ${kv} SET expires_at = now() - interval '1 hour' WHERE table_name = $1`, [Parent.name]);
|
||||
}
|
||||
const delMeta = {action: 'delete', table: Parent, where: [eq('user_id', 'user_id')]} as AnyMeta;
|
||||
await legacy.executeQuery({cql: '__d__', params: {user_id: 1n}, kvMeta: delMeta});
|
||||
await next.executeQuery({cql: '__d__', params: {user_id: 1n}, kvMeta: delMeta});
|
||||
expect(await dump(NEXT_TABLE, Parent.name)).toBe(await dump(LEGACY_TABLE, Parent.name));
|
||||
});
|
||||
|
||||
it('delete with no where clause matches legacy', async () => {
|
||||
await reset(Parent, seedRows);
|
||||
const delMeta = {action: 'delete', table: Parent, where: []} as AnyMeta;
|
||||
await legacy.executeQuery({cql: '__da__', params: {}, kvMeta: delMeta});
|
||||
await next.executeQuery({cql: '__da__', params: {}, kvMeta: delMeta});
|
||||
expect(await dump(NEXT_TABLE, Parent.name)).toBe(await dump(LEGACY_TABLE, Parent.name));
|
||||
});
|
||||
|
||||
it('delete does not touch a neighbouring logical table', async () => {
|
||||
await reset(Parent, seedRows);
|
||||
await reset(Single, [{token_: 'a', note: '1'}]);
|
||||
const delMeta = {action: 'delete', table: Parent, where: []} as AnyMeta;
|
||||
await next.executeQuery({cql: '__da2__', params: {}, kvMeta: delMeta});
|
||||
const left = await raw.query<{count: string}>(`SELECT count(*) AS count FROM ${NEXT_TABLE} WHERE table_name = $1`, [
|
||||
Single.name,
|
||||
]);
|
||||
expect(left.rows[0]!.count).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {execFileSync, spawnSync} from 'node:child_process';
|
||||
import {createServer} from 'node:net';
|
||||
import {
|
||||
getDefaultPostgresClient,
|
||||
type IPostgresClient,
|
||||
initPostgres,
|
||||
type PostgresQueryable,
|
||||
shutdownPostgres,
|
||||
} from '@pkgs/postgres/src/Client';
|
||||
import cassandra from 'cassandra-driver';
|
||||
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||
import {LegacyPostgresKvQueryExecutor} from './__testref__/LegacyPostgresKvQueryExecutor';
|
||||
import type {CassandraParams, KvQueryMeta, KvTableSpec, WhereExpr} from './CassandraTypes';
|
||||
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from './PostgresKvQueryExecutor';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type AnyMeta = KvQueryMeta<Row>;
|
||||
|
||||
const LEGACY_TABLE = 'kvm_legacy';
|
||||
const NEXT_TABLE = 'kvm_next';
|
||||
const CONTAINER = `fluxer-kvm-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
||||
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
||||
|
||||
class TableClient implements IPostgresClient {
|
||||
constructor(
|
||||
private readonly inner: IPostgresClient,
|
||||
private readonly table: string,
|
||||
) {}
|
||||
async query<T extends Record<string, unknown>>(text: string, values: Array<unknown> = []) {
|
||||
return (await this.inner.query(text, values)) as unknown as Awaited<ReturnType<IPostgresClient['query']>> & {
|
||||
rows: Array<T>;
|
||||
};
|
||||
}
|
||||
async connect(): Promise<void> {
|
||||
await this.inner.connect();
|
||||
}
|
||||
async shutdown(): Promise<void> {}
|
||||
isConnected(): boolean {
|
||||
return this.inner.isConnected();
|
||||
}
|
||||
async transaction<T>(fn: (client: PostgresQueryable) => Promise<T>): Promise<T> {
|
||||
return this.inner.transaction(fn);
|
||||
}
|
||||
kvTable(): string {
|
||||
return this.table;
|
||||
}
|
||||
}
|
||||
|
||||
const Two: KvTableSpec<Row> = {
|
||||
name: 'mx_two',
|
||||
columns: ['k', 'c', 'v'],
|
||||
primaryKey: ['k', 'c'],
|
||||
partitionKey: ['k', 'c'],
|
||||
};
|
||||
const PartTwo: KvTableSpec<Row> = {
|
||||
name: 'mx_part',
|
||||
columns: ['k', 'c', 'v'],
|
||||
primaryKey: ['k', 'c'],
|
||||
partitionKey: ['k'],
|
||||
};
|
||||
const Three: KvTableSpec<Row> = {
|
||||
name: 'mx_three',
|
||||
columns: ['k', 'c', 'd', 'v'],
|
||||
primaryKey: ['k', 'c', 'd'],
|
||||
partitionKey: ['k', 'c', 'd'],
|
||||
};
|
||||
|
||||
const KEY_VALUES: Array<[string, unknown]> = [
|
||||
['null', null],
|
||||
['num0', 0],
|
||||
['num1', 1],
|
||||
['num10', 10],
|
||||
['big1', 1n],
|
||||
['str1', '1'],
|
||||
['strq', 'a"b\\c'],
|
||||
['strctl', 'xy'],
|
||||
['strastral', '\u{1f600}'],
|
||||
['strspace', 'a b'],
|
||||
['true', true],
|
||||
['false', false],
|
||||
['date', new Date(0)],
|
||||
['buf', Buffer.from('a')],
|
||||
['localdate', cassandra.types.LocalDate.fromString('2020-01-01')],
|
||||
['localdate-str', '2020-01-01'],
|
||||
['emptystr', ''],
|
||||
];
|
||||
|
||||
function bigintJson(_key: string, value: unknown): unknown {
|
||||
if (typeof value === 'bigint') return `bigint:${value.toString()}`;
|
||||
if (Buffer.isBuffer(value)) return `buf:${value.toString('base64')}`;
|
||||
return value;
|
||||
}
|
||||
|
||||
describe.skipIf(!dockerAvailable)('postgres kv non-select matrix', () => {
|
||||
let raw: IPostgresClient;
|
||||
let legacy: LegacyPostgresKvQueryExecutor;
|
||||
let next: PostgresKvQueryExecutor;
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (typeof address === 'string' || address === null) {
|
||||
reject(new Error('no port'));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
async function dump(kv: string, tableName: string): Promise<string> {
|
||||
const result = await raw.query<{row_key: string}>(
|
||||
`SELECT row_key FROM ${kv} WHERE table_name = $1 ORDER BY row_key COLLATE "C"`,
|
||||
[tableName],
|
||||
);
|
||||
return JSON.stringify(result.rows.map((r) => r.row_key));
|
||||
}
|
||||
async function seed(spec: KvTableSpec<Row>, rows: ReadonlyArray<Row>): Promise<void> {
|
||||
for (const kv of [LEGACY_TABLE, NEXT_TABLE]) {
|
||||
await raw.query(`DELETE FROM ${kv} WHERE table_name = $1`, [spec.name]);
|
||||
}
|
||||
const meta = {action: 'upsert', table: spec} as AnyMeta;
|
||||
for (const row of rows) {
|
||||
await legacy.executeQuery({cql: '__s__', params: row as CassandraParams, kvMeta: meta});
|
||||
await next.executeQuery({cql: '__s__', params: row as CassandraParams, kvMeta: meta});
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const port = await freePort();
|
||||
execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
CONTAINER,
|
||||
'-e',
|
||||
'POSTGRES_USER=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_PASSWORD=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_DB=fluxer',
|
||||
'-p',
|
||||
`127.0.0.1:${port}:5432`,
|
||||
'postgres:16-alpine',
|
||||
'-c',
|
||||
'fsync=off',
|
||||
],
|
||||
{stdio: 'ignore'},
|
||||
);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
|
||||
await sleep(500);
|
||||
if (
|
||||
spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {stdio: 'ignore'})
|
||||
.status !== 0
|
||||
)
|
||||
continue;
|
||||
try {
|
||||
await initPostgres({url: `postgres://fluxer:[email protected]:${port}/fluxer`, maxConnections: 8});
|
||||
await getDefaultPostgresClient().query('SELECT 1');
|
||||
ready = true;
|
||||
} catch {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (!ready) throw new Error('postgres never came up');
|
||||
raw = getDefaultPostgresClient();
|
||||
await ensurePostgresKvSchema(new TableClient(raw, LEGACY_TABLE));
|
||||
await ensurePostgresKvSchema(new TableClient(raw, NEXT_TABLE));
|
||||
legacy = new LegacyPostgresKvQueryExecutor(new TableClient(raw, LEGACY_TABLE));
|
||||
next = new PostgresKvQueryExecutor(new TableClient(raw, NEXT_TABLE));
|
||||
}, 900_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
|
||||
it('count and delete agree with legacy for every key value type', async () => {
|
||||
const rows = KEY_VALUES.map(([, value], i) => ({k: value, c: BigInt(i), v: i}));
|
||||
const countMismatch: Array<string> = [];
|
||||
const deleteMismatch: Array<string> = [];
|
||||
const selectMismatch: Array<string> = [];
|
||||
for (const [name, value] of KEY_VALUES) {
|
||||
await seed(Two, rows);
|
||||
const where = [{kind: 'eq', col: 'k', param: 'k'}] as Array<WhereExpr<Row>>;
|
||||
const params = {k: value} as CassandraParams;
|
||||
const sel = {action: 'select', table: Two, where, columns: Two.columns} as AnyMeta;
|
||||
const l = await legacy.executeQuery({cql: '__sel__', params, kvMeta: sel});
|
||||
const n = await next.executeQuery({cql: '__sel__', params, kvMeta: sel});
|
||||
if (JSON.stringify(l, bigintJson) !== JSON.stringify(n, bigintJson)) {
|
||||
selectMismatch.push(`${name}: legacy=${JSON.stringify(l, bigintJson)} next=${JSON.stringify(n, bigintJson)}`);
|
||||
}
|
||||
const cnt = {action: 'count', table: Two, where} as AnyMeta;
|
||||
const lc = await legacy.executeQuery({cql: '__cnt__', params, kvMeta: cnt});
|
||||
const nc = await next.executeQuery({cql: '__cnt__', params, kvMeta: cnt});
|
||||
if (JSON.stringify(lc) !== JSON.stringify(nc)) {
|
||||
countMismatch.push(`${name}: legacy=${JSON.stringify(lc)} next=${JSON.stringify(nc)}`);
|
||||
}
|
||||
const del = {action: 'delete', table: Two, where} as AnyMeta;
|
||||
await legacy.executeQuery({cql: '__del__', params, kvMeta: del});
|
||||
await next.executeQuery({cql: '__del__', params, kvMeta: del});
|
||||
const ld = await dump(LEGACY_TABLE, Two.name);
|
||||
const nd = await dump(NEXT_TABLE, Two.name);
|
||||
if (ld !== nd) deleteMismatch.push(`${name}: legacy=${ld} next=${nd}`);
|
||||
}
|
||||
expect({selectMismatch, countMismatch, deleteMismatch}).toEqual({
|
||||
selectMismatch: [],
|
||||
countMismatch: [],
|
||||
deleteMismatch: [],
|
||||
});
|
||||
}, 300_000);
|
||||
|
||||
it('explicit partition key tables agree for count and delete', async () => {
|
||||
const rows = [
|
||||
{k: 1n, c: 1n, v: 1},
|
||||
{k: 1n, c: 2n, v: 2},
|
||||
{k: 2n, c: 1n, v: 3},
|
||||
];
|
||||
await seed(PartTwo, rows);
|
||||
const where = [{kind: 'eq', col: 'k', param: 'k'}] as Array<WhereExpr<Row>>;
|
||||
const params = {k: 1n} as CassandraParams;
|
||||
const lc = await legacy.executeQuery({
|
||||
cql: '__pc__',
|
||||
params,
|
||||
kvMeta: {action: 'count', table: PartTwo, where} as AnyMeta,
|
||||
});
|
||||
const nc = await next.executeQuery({
|
||||
cql: '__pc__',
|
||||
params,
|
||||
kvMeta: {action: 'count', table: PartTwo, where} as AnyMeta,
|
||||
});
|
||||
expect(nc).toEqual(lc);
|
||||
await legacy.executeQuery({cql: '__pd__', params, kvMeta: {action: 'delete', table: PartTwo, where} as AnyMeta});
|
||||
await next.executeQuery({cql: '__pd__', params, kvMeta: {action: 'delete', table: PartTwo, where} as AnyMeta});
|
||||
expect(await dump(NEXT_TABLE, PartTwo.name)).toBe(await dump(LEGACY_TABLE, PartTwo.name));
|
||||
});
|
||||
|
||||
it('multi column IN products agree, including above the combination cap', async () => {
|
||||
const rows: Array<Row> = [];
|
||||
for (let a = 0; a < 40; a += 1) {
|
||||
for (let b = 0; b < 40; b += 1) rows.push({k: BigInt(a), c: BigInt(b), v: a * b});
|
||||
}
|
||||
await seed(Two, rows);
|
||||
const ks = Array.from({length: 40}, (_, i) => BigInt(i));
|
||||
const cs = Array.from({length: 40}, (_, i) => BigInt(i));
|
||||
const where = [
|
||||
{kind: 'in', col: 'k', param: 'ks'},
|
||||
{kind: 'in', col: 'c', param: 'cs'},
|
||||
] as Array<WhereExpr<Row>>;
|
||||
for (const [label, params] of [
|
||||
['small', {ks: ks.slice(0, 3), cs: cs.slice(0, 3)}],
|
||||
['cap', {ks, cs}],
|
||||
['dupes', {ks: [1n, 1n, 2n], cs: [1n, 1n]}],
|
||||
['empty', {ks: [], cs}],
|
||||
] as Array<[string, CassandraParams]>) {
|
||||
const lc = await legacy.executeQuery({
|
||||
cql: `__mc_${label}__`,
|
||||
params,
|
||||
kvMeta: {action: 'count', table: Two, where} as AnyMeta,
|
||||
});
|
||||
const nc = await next.executeQuery({
|
||||
cql: `__mc_${label}__`,
|
||||
params,
|
||||
kvMeta: {action: 'count', table: Two, where} as AnyMeta,
|
||||
});
|
||||
expect([label, nc]).toEqual([label, lc]);
|
||||
const ls = await legacy.executeQuery({
|
||||
cql: `__ms_${label}__`,
|
||||
params,
|
||||
kvMeta: {action: 'select', table: Two, where, columns: Two.columns} as AnyMeta,
|
||||
});
|
||||
const ns = await next.executeQuery({
|
||||
cql: `__ms_${label}__`,
|
||||
params,
|
||||
kvMeta: {action: 'select', table: Two, where, columns: Two.columns} as AnyMeta,
|
||||
});
|
||||
expect([label, JSON.stringify(ns, bigintJson)]).toEqual([label, JSON.stringify(ls, bigintJson)]);
|
||||
}
|
||||
await legacy.executeQuery({
|
||||
cql: '__md__',
|
||||
params: {ks, cs} as CassandraParams,
|
||||
kvMeta: {action: 'delete', table: Two, where} as AnyMeta,
|
||||
});
|
||||
await next.executeQuery({
|
||||
cql: '__md__',
|
||||
params: {ks, cs} as CassandraParams,
|
||||
kvMeta: {action: 'delete', table: Two, where} as AnyMeta,
|
||||
});
|
||||
expect(await dump(NEXT_TABLE, Two.name)).toBe(await dump(LEGACY_TABLE, Two.name));
|
||||
}, 300_000);
|
||||
|
||||
it('count with a limit or an order by matches legacy', async () => {
|
||||
await seed(Two, [
|
||||
{k: 1n, c: 1n, v: 1},
|
||||
{k: 1n, c: 2n, v: 2},
|
||||
{k: 1n, c: 3n, v: 3},
|
||||
]);
|
||||
const where = [{kind: 'eq', col: 'k', param: 'k'}] as Array<WhereExpr<Row>>;
|
||||
for (const extra of [{limit: 2}, {orderBy: {col: 'c', direction: 'DESC'}}, {}]) {
|
||||
const meta = {action: 'count', table: Two, where, ...extra} as AnyMeta;
|
||||
const l = await legacy.executeQuery({cql: `__cl__${JSON.stringify(extra)}`, params: {k: 1n}, kvMeta: meta});
|
||||
const n = await next.executeQuery({cql: `__cl__${JSON.stringify(extra)}`, params: {k: 1n}, kvMeta: meta});
|
||||
expect([JSON.stringify(extra), n]).toEqual([JSON.stringify(extra), l]);
|
||||
}
|
||||
});
|
||||
|
||||
it('count and delete with a gap in the primary key match legacy', async () => {
|
||||
const rows: Array<Row> = [];
|
||||
for (const c of [1n, 2n]) {
|
||||
for (const d of [1n, 2n]) rows.push({k: 1n, c, d, v: 1});
|
||||
}
|
||||
rows.push({k: 2n, c: 1n, d: 1n, v: 9});
|
||||
await seed(Three, rows);
|
||||
const where = [
|
||||
{kind: 'eq', col: 'k', param: 'k'},
|
||||
{kind: 'eq', col: 'd', param: 'd'},
|
||||
] as Array<WhereExpr<Row>>;
|
||||
const params = {k: 1n, d: 1n} as CassandraParams;
|
||||
const lc = await legacy.executeQuery({
|
||||
cql: '__gc__',
|
||||
params,
|
||||
kvMeta: {action: 'count', table: Three, where} as AnyMeta,
|
||||
});
|
||||
const nc = await next.executeQuery({
|
||||
cql: '__gc__',
|
||||
params,
|
||||
kvMeta: {action: 'count', table: Three, where} as AnyMeta,
|
||||
});
|
||||
expect(nc).toEqual(lc);
|
||||
await legacy.executeQuery({cql: '__gd__', params, kvMeta: {action: 'delete', table: Three, where} as AnyMeta});
|
||||
await next.executeQuery({cql: '__gd__', params, kvMeta: {action: 'delete', table: Three, where} as AnyMeta});
|
||||
expect(await dump(NEXT_TABLE, Three.name)).toBe(await dump(LEGACY_TABLE, Three.name));
|
||||
});
|
||||
|
||||
it('delete with a non key where column matches legacy', async () => {
|
||||
await seed(Two, [
|
||||
{k: 1n, c: 1n, v: 5},
|
||||
{k: 1n, c: 2n, v: 6},
|
||||
{k: 2n, c: 1n, v: 5},
|
||||
]);
|
||||
const where = [
|
||||
{kind: 'eq', col: 'k', param: 'k'},
|
||||
{kind: 'eq', col: 'v', param: 'v'},
|
||||
] as Array<WhereExpr<Row>>;
|
||||
const params = {k: 1n, v: 5} as CassandraParams;
|
||||
await legacy.executeQuery({cql: '__nd__', params, kvMeta: {action: 'delete', table: Two, where} as AnyMeta});
|
||||
await next.executeQuery({cql: '__nd__', params, kvMeta: {action: 'delete', table: Two, where} as AnyMeta});
|
||||
expect(await dump(NEXT_TABLE, Two.name)).toBe(await dump(LEGACY_TABLE, Two.name));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,544 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {execFileSync, spawnSync} from 'node:child_process';
|
||||
import {createServer} from 'node:net';
|
||||
import {
|
||||
getDefaultPostgresClient,
|
||||
type IPostgresClient,
|
||||
initPostgres,
|
||||
type PostgresQueryable,
|
||||
shutdownPostgres,
|
||||
} from '@pkgs/postgres/src/Client';
|
||||
import cassandra from 'cassandra-driver';
|
||||
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||
import {LegacyPostgresKvQueryExecutor} from './__testref__/LegacyPostgresKvQueryExecutor';
|
||||
import {defineTable} from './CassandraTableDsl';
|
||||
import type {CassandraParam, CassandraParams, KvQueryMeta, KvTableSpec, WhereExpr} from './CassandraTypes';
|
||||
import {buildCandidatePlan, ensurePostgresKvSchema, PostgresKvQueryExecutor} from './PostgresKvQueryExecutor';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type AnyMeta = KvQueryMeta<Row>;
|
||||
type AnyWhere = WhereExpr<Row>;
|
||||
|
||||
const KV = 'kv_order_attack';
|
||||
const POSTGRES_IMAGE = 'postgres:16-alpine';
|
||||
const CONTAINER = `fluxer-kvorder-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
||||
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
||||
|
||||
const BigChild = defineTable<{owner: unknown; seq: unknown; v: unknown}, 'owner' | 'seq'>({
|
||||
name: 'attack_bigint_child',
|
||||
columns: ['owner', 'seq', 'v'],
|
||||
primaryKey: ['owner', 'seq'],
|
||||
});
|
||||
|
||||
const NumChild = defineTable<{owner: unknown; seq: unknown; v: unknown}, 'owner' | 'seq'>({
|
||||
name: 'attack_number_child',
|
||||
columns: ['owner', 'seq', 'v'],
|
||||
primaryKey: ['owner', 'seq'],
|
||||
});
|
||||
|
||||
const DateChild = defineTable<{owner: unknown; at: unknown; id: unknown; v: unknown}, 'owner' | 'at' | 'id'>({
|
||||
name: 'attack_date_child',
|
||||
columns: ['owner', 'at', 'id', 'v'],
|
||||
primaryKey: ['owner', 'at', 'id'],
|
||||
});
|
||||
|
||||
const StrChild = defineTable<{owner: unknown; seq: unknown; v: unknown}, 'owner' | 'seq'>({
|
||||
name: 'attack_string_child',
|
||||
columns: ['owner', 'seq', 'v'],
|
||||
primaryKey: ['owner', 'seq'],
|
||||
});
|
||||
|
||||
const ScanBig = defineTable<{id: unknown; v: unknown}, 'id'>({
|
||||
name: 'attack_scan_bigint',
|
||||
columns: ['id', 'v'],
|
||||
primaryKey: ['id'],
|
||||
});
|
||||
|
||||
const Messages = defineTable<
|
||||
{channel_id: unknown; bucket: unknown; message_id: unknown; content: unknown},
|
||||
'channel_id' | 'bucket' | 'message_id',
|
||||
'channel_id' | 'bucket'
|
||||
>({
|
||||
name: 'attack_messages',
|
||||
columns: ['channel_id', 'bucket', 'message_id', 'content'],
|
||||
primaryKey: ['channel_id', 'bucket', 'message_id'],
|
||||
partitionKey: ['channel_id', 'bucket'],
|
||||
});
|
||||
|
||||
const LocalDateChild = defineTable<{owner: unknown; seq: unknown; v: unknown}, 'owner' | 'seq'>({
|
||||
name: 'attack_localdate_child',
|
||||
columns: ['owner', 'seq', 'v'],
|
||||
primaryKey: ['owner', 'seq'],
|
||||
});
|
||||
|
||||
function eq(col: string, param: string): AnyWhere {
|
||||
return {kind: 'eq', col, param} as AnyWhere;
|
||||
}
|
||||
|
||||
function inClause(col: string, param: string): AnyWhere {
|
||||
return {kind: 'in', col, param} as AnyWhere;
|
||||
}
|
||||
|
||||
function cmp(kind: 'lt' | 'lte' | 'gt' | 'gte', col: string, param: string): AnyWhere {
|
||||
return {kind, col, param} as AnyWhere;
|
||||
}
|
||||
|
||||
function spec(table: unknown): KvTableSpec<Row> {
|
||||
return table as unknown as KvTableSpec<Row>;
|
||||
}
|
||||
|
||||
function selectMeta(table: unknown, where: Array<AnyWhere>, extra: Partial<AnyMeta> = {}): AnyMeta {
|
||||
const s = spec(table);
|
||||
return {action: 'select', table: s, where, columns: s.columns, ...extra} as AnyMeta;
|
||||
}
|
||||
|
||||
function canonical(value: unknown): unknown {
|
||||
if (value === undefined) return {__c: 'undefined'};
|
||||
if (value === null) return null;
|
||||
if (typeof value === 'bigint') return {__c: 'bigint', v: value.toString()};
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isNaN(value)) return {__c: 'nan'};
|
||||
if (!Number.isFinite(value)) return {__c: 'inf', v: value > 0 ? 1 : -1};
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Date) return {__c: 'date', v: value.toISOString()};
|
||||
if (Buffer.isBuffer(value)) return {__c: 'buffer', v: value.toString('base64')};
|
||||
if (typeof value === 'object' && value.constructor?.name === 'LocalDate') return {__c: 'localdate', v: String(value)};
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
if (typeof value === 'object') {
|
||||
const source = value as Record<string, unknown>;
|
||||
return {
|
||||
__c: 'obj',
|
||||
v: Object.keys(source)
|
||||
.sort()
|
||||
.map((k) => [k, canonical(source[k])]),
|
||||
};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function fp(rows: ReadonlyArray<unknown>): string {
|
||||
return JSON.stringify(rows.map(canonical));
|
||||
}
|
||||
|
||||
function multiset(rows: ReadonlyArray<unknown>): string {
|
||||
return JSON.stringify(rows.map((r) => JSON.stringify(canonical(r))).sort());
|
||||
}
|
||||
|
||||
const suite = dockerAvailable ? describe : describe.skip;
|
||||
|
||||
suite('PostgresKvQueryExecutor ordering and limit attack', () => {
|
||||
let raw: IPostgresClient;
|
||||
let legacy: LegacyPostgresKvQueryExecutor;
|
||||
let next: PostgresKvQueryExecutor;
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (typeof address === 'string' || address === null) {
|
||||
reject(new Error('no port'));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function client(): IPostgresClient {
|
||||
const inner = raw;
|
||||
return {
|
||||
query: (text: string, values?: Array<unknown>) => inner.query(text, values),
|
||||
connect: () => inner.connect(),
|
||||
shutdown: async () => {},
|
||||
isConnected: () => inner.isConnected(),
|
||||
transaction: <T>(fn: (db: PostgresQueryable) => Promise<T>) => inner.transaction(fn),
|
||||
kvTable: () => KV,
|
||||
} as unknown as IPostgresClient;
|
||||
}
|
||||
|
||||
async function seed(table: unknown, rows: ReadonlyArray<Row>): Promise<void> {
|
||||
const s = spec(table);
|
||||
await raw.query(`DELETE FROM ${KV} WHERE table_name = $1`, [s.name]);
|
||||
const meta = {action: 'upsert', table: s} as AnyMeta;
|
||||
for (const row of rows) {
|
||||
await legacy.executeQuery({cql: `__seed__${s.name}`, params: row as CassandraParams, kvMeta: meta});
|
||||
}
|
||||
}
|
||||
|
||||
async function collectPages(
|
||||
executor: LegacyPostgresKvQueryExecutor | PostgresKvQueryExecutor,
|
||||
meta: AnyMeta,
|
||||
params: CassandraParams,
|
||||
pageSize: number,
|
||||
): Promise<Array<Array<unknown>>> {
|
||||
const pages: Array<Array<unknown>> = [];
|
||||
let pageState: string | null = null;
|
||||
for (let guard = 0; guard < 200; guard += 1) {
|
||||
const page: {rows: Array<unknown>; pageState: string | null} = await executor.executePagedQuery(
|
||||
{cql: '__page__', params, kvMeta: meta},
|
||||
{pageSize, pageState},
|
||||
);
|
||||
pages.push(page.rows);
|
||||
pageState = page.pageState;
|
||||
if (pageState === null) break;
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const port = await freePort();
|
||||
execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
CONTAINER,
|
||||
'-e',
|
||||
'POSTGRES_USER=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_PASSWORD=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_DB=fluxer',
|
||||
'-p',
|
||||
`127.0.0.1:${port}:5432`,
|
||||
POSTGRES_IMAGE,
|
||||
'-c',
|
||||
'fsync=off',
|
||||
'-c',
|
||||
'synchronous_commit=off',
|
||||
'-c',
|
||||
'full_page_writes=off',
|
||||
],
|
||||
{stdio: 'ignore'},
|
||||
);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
|
||||
await sleep(500);
|
||||
const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (probe.status !== 0) continue;
|
||||
try {
|
||||
await initPostgres({url: `postgres://fluxer:[email protected]:${port}/fluxer`, maxConnections: 6});
|
||||
await getDefaultPostgresClient().query('SELECT 1');
|
||||
ready = true;
|
||||
} catch {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (!ready) throw new Error('postgres not ready');
|
||||
raw = getDefaultPostgresClient();
|
||||
const c = client();
|
||||
await ensurePostgresKvSchema(c);
|
||||
legacy = new LegacyPostgresKvQueryExecutor(c);
|
||||
next = new PostgresKvQueryExecutor(c);
|
||||
}, 900_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
|
||||
it('A1 keyset paging over a bigint clustering column keeps legacy page boundaries', async () => {
|
||||
const seqs = [1n, 2n, 9n, 10n, 11n, 100n, 101n, 999n, 1000n];
|
||||
await seed(
|
||||
BigChild,
|
||||
seqs.map((seq) => ({owner: 'o', seq, v: seq.toString()})),
|
||||
);
|
||||
const meta = selectMeta(BigChild, [eq('owner', 'owner')]);
|
||||
const params: CassandraParams = {owner: 'o'};
|
||||
const plan = buildCandidatePlan(meta, params);
|
||||
expect(plan.exact).toBe(true);
|
||||
expect(plan.candidates.kind).toBe('range');
|
||||
const left = await collectPages(legacy, meta, params, 3);
|
||||
const right = await collectPages(next, meta, params, 3);
|
||||
expect(multiset(left.flat())).toBe(multiset(right.flat()));
|
||||
expect(right.map((p) => p.map((r) => String((r as Row).seq)))).toStrictEqual(
|
||||
left.map((p) => p.map((r) => String((r as Row).seq))),
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
it('A2 keyset paging over a plain number clustering column keeps legacy page boundaries', async () => {
|
||||
const seqs = [1, 2, 9, 10, 11, 100, 1e21, -1];
|
||||
await seed(
|
||||
NumChild,
|
||||
seqs.map((seq) => ({owner: 'o', seq, v: String(seq)})),
|
||||
);
|
||||
const meta = selectMeta(NumChild, [eq('owner', 'owner')]);
|
||||
const params: CassandraParams = {owner: 'o'};
|
||||
const left = await collectPages(legacy, meta, params, 3);
|
||||
const right = await collectPages(next, meta, params, 3);
|
||||
expect(multiset(left.flat())).toBe(multiset(right.flat()));
|
||||
expect(fp(right.flat())).toBe(fp(left.flat()));
|
||||
}, 120_000);
|
||||
|
||||
it('A3 keyset paging over a whole-table bigint scan keeps legacy page boundaries', async () => {
|
||||
const ids = [1n, 2n, 9n, 10n, 11n, 100n, 1000n];
|
||||
await seed(
|
||||
ScanBig,
|
||||
ids.map((id) => ({id, v: id.toString()})),
|
||||
);
|
||||
const meta = selectMeta(ScanBig, []);
|
||||
const plan = buildCandidatePlan(meta, {});
|
||||
expect(plan.exact).toBe(true);
|
||||
expect(plan.candidates.kind).toBe('scan');
|
||||
const left = await collectPages(legacy, meta, {}, 3);
|
||||
const right = await collectPages(next, meta, {}, 3);
|
||||
expect(multiset(left.flat())).toBe(multiset(right.flat()));
|
||||
expect(fp(right.flat())).toBe(fp(left.flat()));
|
||||
}, 120_000);
|
||||
|
||||
it('A4 keyset paging over a Date clustering column keeps legacy page boundaries', async () => {
|
||||
const dates = [
|
||||
new Date('1969-12-31T23:59:59.999Z'),
|
||||
new Date(0),
|
||||
new Date('2020-01-02T03:04:05.006Z'),
|
||||
new Date('2024-06-07T08:09:10.011Z'),
|
||||
new Date('2999-01-01T00:00:00.000Z'),
|
||||
new Date(-62167219200000),
|
||||
new Date(8640000000000000),
|
||||
];
|
||||
await seed(
|
||||
DateChild,
|
||||
dates.map((at, index) => ({owner: 'o', at, id: BigInt(index), v: at.toISOString()})),
|
||||
);
|
||||
const meta = selectMeta(DateChild, [eq('owner', 'owner')]);
|
||||
const params: CassandraParams = {owner: 'o'};
|
||||
const left = await collectPages(legacy, meta, params, 2);
|
||||
const right = await collectPages(next, meta, params, 2);
|
||||
expect(multiset(left.flat())).toBe(multiset(right.flat()));
|
||||
expect(fp(right.flat())).toBe(fp(left.flat()));
|
||||
}, 120_000);
|
||||
|
||||
it('A5 keyset paging over a string clustering column keeps legacy page boundaries', async () => {
|
||||
const seqs = ['a', 'b', 'A', 'ab', 'a b', '\u{1f600}', '�', 'a"b', 'a\\b', ''];
|
||||
await seed(
|
||||
StrChild,
|
||||
seqs.map((seq, index) => ({owner: 'o', seq, v: index})),
|
||||
);
|
||||
const meta = selectMeta(StrChild, [eq('owner', 'owner')]);
|
||||
const params: CassandraParams = {owner: 'o'};
|
||||
const left = await collectPages(legacy, meta, params, 3);
|
||||
const right = await collectPages(next, meta, params, 3);
|
||||
expect(multiset(left.flat())).toBe(multiset(right.flat()));
|
||||
expect(fp(right.flat())).toBe(fp(left.flat()));
|
||||
}, 120_000);
|
||||
|
||||
it('A6 a keyset page is never empty while it reports more pages', async () => {
|
||||
const seqs = [1n, 2n, 9n, 10n, 11n, 100n, 101n];
|
||||
await seed(
|
||||
BigChild,
|
||||
seqs.map((seq) => ({owner: 'o', seq, v: seq.toString()})),
|
||||
);
|
||||
const meta = selectMeta(BigChild, [eq('owner', 'owner')]);
|
||||
const pages = await collectPages(next, meta, {owner: 'o'}, 2);
|
||||
for (let index = 0; index < pages.length - 1; index += 1) {
|
||||
expect(pages[index]!.length).toBeGreaterThan(0);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it('B1 select with a LIMIT returns the same rows as the legacy executor', async () => {
|
||||
const seqs = [1n, 2n, 9n, 10n, 11n, 100n, 101n, 999n, 1000n];
|
||||
const rows: Array<Row> = [];
|
||||
for (const owner of ['o', 'p']) for (const seq of seqs) rows.push({owner, seq, v: seq.toString()});
|
||||
await seed(BigChild, rows);
|
||||
const mismatches: Array<string> = [];
|
||||
for (const limit of [1, 2, 3, 5, 100]) {
|
||||
for (const direction of [undefined, 'ASC', 'DESC'] as const) {
|
||||
const extra: Partial<AnyMeta> = {limit};
|
||||
if (direction) extra.orderBy = {col: 'seq', direction} as AnyMeta['orderBy'];
|
||||
const meta = selectMeta(BigChild, [eq('owner', 'owner')], extra);
|
||||
const params: CassandraParams = {owner: 'o'};
|
||||
const l = await legacy.executeQuery({cql: '__l__', params, kvMeta: meta});
|
||||
const r = await next.executeQuery({cql: '__l__', params, kvMeta: meta});
|
||||
if (fp(l) !== fp(r)) mismatches.push(`limit=${limit} order=${direction}\n legacy ${fp(l)}\n next ${fp(r)}`);
|
||||
}
|
||||
}
|
||||
expect(mismatches.join('\n'), mismatches.join('\n')).toBe('');
|
||||
}, 120_000);
|
||||
|
||||
it('B2 select with a LIMIT and an IN over the leading key returns the same rows', async () => {
|
||||
const rows: Array<Row> = [];
|
||||
for (const owner of ['o', 'p', 'q']) {
|
||||
for (const seq of [1n, 2n, 9n, 10n, 11n, 100n]) rows.push({owner, seq, v: `${owner}${seq}`});
|
||||
}
|
||||
await seed(BigChild, rows);
|
||||
const mismatches: Array<string> = [];
|
||||
for (const limit of [1, 2, 4, 7, 100]) {
|
||||
for (const direction of [undefined, 'ASC', 'DESC'] as const) {
|
||||
const extra: Partial<AnyMeta> = {limit};
|
||||
if (direction) extra.orderBy = {col: 'seq', direction} as AnyMeta['orderBy'];
|
||||
const meta = selectMeta(BigChild, [inClause('owner', 'owners')], extra);
|
||||
const params: CassandraParams = {owners: ['o', 'q', 'o'] as unknown as CassandraParam};
|
||||
const l = await legacy.executeQuery({cql: '__l2__', params, kvMeta: meta});
|
||||
const r = await next.executeQuery({cql: '__l2__', params, kvMeta: meta});
|
||||
if (fp(l) !== fp(r)) mismatches.push(`limit=${limit} order=${direction}\n legacy ${fp(l)}\n next ${fp(r)}`);
|
||||
}
|
||||
}
|
||||
expect(mismatches.join('\n'), mismatches.join('\n')).toBe('');
|
||||
}, 120_000);
|
||||
|
||||
it('B3 select with a LIMIT and a range clause on the clustering column returns the same rows', async () => {
|
||||
const rows: Array<Row> = [];
|
||||
for (const owner of ['o', 'p']) {
|
||||
for (const seq of [1n, 2n, 9n, 10n, 11n, 100n, 101n, 1000n]) rows.push({owner, seq, v: `${owner}${seq}`});
|
||||
}
|
||||
await seed(BigChild, rows);
|
||||
const mismatches: Array<string> = [];
|
||||
for (const kind of ['lt', 'lte', 'gt', 'gte'] as const) {
|
||||
for (const bound of [1n, 10n, 11n, 100n]) {
|
||||
for (const limit of [1, 2, 3, 100]) {
|
||||
for (const direction of ['ASC', 'DESC'] as const) {
|
||||
const meta = selectMeta(BigChild, [eq('owner', 'owner'), cmp(kind, 'seq', 'bound')], {
|
||||
limit,
|
||||
orderBy: {col: 'seq', direction} as AnyMeta['orderBy'],
|
||||
});
|
||||
const params: CassandraParams = {owner: 'o', bound};
|
||||
const l = await legacy.executeQuery({cql: '__l3__', params, kvMeta: meta});
|
||||
const r = await next.executeQuery({cql: '__l3__', params, kvMeta: meta});
|
||||
if (fp(l) !== fp(r)) {
|
||||
mismatches.push(`${kind} ${bound} limit=${limit} ${direction}\n legacy ${fp(l)}\n next ${fp(r)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(mismatches.join('\n'), mismatches.join('\n')).toBe('');
|
||||
}, 240_000);
|
||||
|
||||
it('B4 message history shaped select returns the same page as the legacy executor', async () => {
|
||||
const rows: Array<Row> = [];
|
||||
for (const channel of [10n, 11n]) {
|
||||
for (const bucket of [0, 1, 2, 10, 11]) {
|
||||
for (const messageId of [1n, 2n, 9n, 10n, 11n, 100n, 101n, 999n, 1000n, 9999999999999999999n]) {
|
||||
rows.push({channel_id: channel, bucket, message_id: messageId, content: `${channel}/${bucket}/${messageId}`});
|
||||
}
|
||||
}
|
||||
}
|
||||
await seed(Messages, rows);
|
||||
const mismatches: Array<string> = [];
|
||||
for (const bucket of [0, 1, 10]) {
|
||||
for (const cursor of [1n, 10n, 11n, 100n, 1000n, 9999999999999999999n]) {
|
||||
for (const kind of ['lt', 'gt'] as const) {
|
||||
for (const direction of ['ASC', 'DESC'] as const) {
|
||||
for (const limit of [1, 3, 50]) {
|
||||
const meta = selectMeta(
|
||||
Messages,
|
||||
[eq('channel_id', 'channel_id'), eq('bucket', 'bucket'), cmp(kind, 'message_id', 'cursor')],
|
||||
{limit, orderBy: {col: 'message_id', direction} as AnyMeta['orderBy']},
|
||||
);
|
||||
const params: CassandraParams = {channel_id: 10n, bucket, cursor};
|
||||
const l = await legacy.executeQuery({cql: '__b4__', params, kvMeta: meta});
|
||||
const r = await next.executeQuery({cql: '__b4__', params, kvMeta: meta});
|
||||
if (fp(l) !== fp(r)) {
|
||||
mismatches.push(`bucket=${bucket} ${kind} ${cursor} ${direction} limit=${limit}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(mismatches.join('\n'), mismatches.join('\n')).toBe('');
|
||||
}, 300_000);
|
||||
|
||||
it('B5 select with a LIMIT under a demoted over-cap cartesian plan returns the same rows', async () => {
|
||||
const rows: Array<Row> = [];
|
||||
for (const owner of ['o', 'p', 'q']) {
|
||||
for (const seq of [1n, 2n, 9n, 10n, 11n, 100n]) rows.push({owner, seq, v: `${owner}${seq}`});
|
||||
}
|
||||
await seed(BigChild, rows);
|
||||
const owners = Array.from({length: 40}, (_unused, index) => `x${index}`);
|
||||
owners.push('o', 'q');
|
||||
const seqs = Array.from({length: 40}, (_unused, index) => BigInt(index));
|
||||
seqs.push(100n);
|
||||
const mismatches: Array<string> = [];
|
||||
for (const limit of [1, 3, 100]) {
|
||||
for (const direction of ['ASC', 'DESC'] as const) {
|
||||
const meta = selectMeta(BigChild, [inClause('owner', 'owners'), inClause('seq', 'seqs')], {
|
||||
limit,
|
||||
orderBy: {col: 'seq', direction} as AnyMeta['orderBy'],
|
||||
});
|
||||
const params: CassandraParams = {
|
||||
owners: owners as unknown as CassandraParam,
|
||||
seqs: seqs as unknown as CassandraParam,
|
||||
};
|
||||
const l = await legacy.executeQuery({cql: '__b5__', params, kvMeta: meta});
|
||||
const r = await next.executeQuery({cql: '__b5__', params, kvMeta: meta});
|
||||
if (fp(l) !== fp(r)) mismatches.push(`limit=${limit} ${direction}\n legacy ${fp(l)}\n next ${fp(r)}`);
|
||||
}
|
||||
}
|
||||
expect(mismatches.join('\n'), mismatches.join('\n')).toBe('');
|
||||
}, 180_000);
|
||||
|
||||
it('B6 select ordered by a column outside the projection returns the same rows', async () => {
|
||||
const rows: Array<Row> = [];
|
||||
for (const seq of [1n, 2n, 9n, 10n, 11n, 100n]) rows.push({owner: 'o', seq, v: `v${seq}`});
|
||||
await seed(BigChild, rows);
|
||||
const mismatches: Array<string> = [];
|
||||
for (const limit of [1, 3, 100]) {
|
||||
for (const direction of ['ASC', 'DESC'] as const) {
|
||||
const meta = selectMeta(BigChild, [eq('owner', 'owner')], {
|
||||
columns: ['v'],
|
||||
limit,
|
||||
orderBy: {col: 'seq', direction} as AnyMeta['orderBy'],
|
||||
});
|
||||
const params: CassandraParams = {owner: 'o'};
|
||||
const l = await legacy.executeQuery({cql: '__b6__', params, kvMeta: meta});
|
||||
const r = await next.executeQuery({cql: '__b6__', params, kvMeta: meta});
|
||||
if (fp(l) !== fp(r)) mismatches.push(`limit=${limit} ${direction}\n legacy ${fp(l)}\n next ${fp(r)}`);
|
||||
}
|
||||
}
|
||||
expect(mismatches.join('\n'), mismatches.join('\n')).toBe('');
|
||||
}, 180_000);
|
||||
|
||||
it('B7 keyset page sizes match legacy page sizes exactly', async () => {
|
||||
const rows: Array<Row> = [];
|
||||
for (const seq of [1n, 2n, 9n, 10n, 11n, 100n, 101n]) rows.push({owner: 'o', seq, v: `v${seq}`});
|
||||
await seed(BigChild, rows);
|
||||
const meta = selectMeta(BigChild, [eq('owner', 'owner')]);
|
||||
const mismatches: Array<string> = [];
|
||||
for (const pageSize of [1, 2, 3, 6, 7, 8, 100]) {
|
||||
const left = await collectPages(legacy, meta, {owner: 'o'}, pageSize);
|
||||
const right = await collectPages(next, meta, {owner: 'o'}, pageSize);
|
||||
const l = left.map((p) => p.length).join(',');
|
||||
const r = right.map((p) => p.length).join(',');
|
||||
if (l !== r) mismatches.push(`pageSize=${pageSize} legacy=${l} next=${r}`);
|
||||
if (multiset(left.flat()) !== multiset(right.flat())) mismatches.push(`pageSize=${pageSize} rowset`);
|
||||
}
|
||||
expect(mismatches.join('\n'), mismatches.join('\n')).toBe('');
|
||||
}, 180_000);
|
||||
|
||||
it('C1 a LocalDate key value is still found by an equal string parameter', async () => {
|
||||
await seed(LocalDateChild, [
|
||||
{owner: cassandra.types.LocalDate.fromString('2020-01-01'), seq: 1n, v: 'localdate'},
|
||||
{owner: '2020-01-01', seq: 2n, v: 'string'},
|
||||
]);
|
||||
const meta = selectMeta(LocalDateChild, [eq('owner', 'owner')], {limit: 10});
|
||||
const params: CassandraParams = {owner: '2020-01-01'};
|
||||
const l = await legacy.executeQuery({cql: '__c1__', params, kvMeta: meta});
|
||||
const r = await next.executeQuery({cql: '__c1__', params, kvMeta: meta});
|
||||
expect(fp(r), `legacy ${fp(l)} next ${fp(r)}`).toBe(fp(l));
|
||||
}, 120_000);
|
||||
|
||||
it('C2 a string key value is still found by an equal LocalDate parameter', async () => {
|
||||
await seed(LocalDateChild, [
|
||||
{owner: cassandra.types.LocalDate.fromString('2020-01-01'), seq: 1n, v: 'localdate'},
|
||||
{owner: '2020-01-01', seq: 2n, v: 'string'},
|
||||
]);
|
||||
const meta = selectMeta(LocalDateChild, [eq('owner', 'owner')], {limit: 10});
|
||||
const params: CassandraParams = {owner: cassandra.types.LocalDate.fromString('2020-01-01')};
|
||||
const l = await legacy.executeQuery({cql: '__c2__', params, kvMeta: meta});
|
||||
const r = await next.executeQuery({cql: '__c2__', params, kvMeta: meta});
|
||||
expect(fp(r), `legacy ${fp(l)} next ${fp(r)}`).toBe(fp(l));
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,562 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {execFileSync, spawnSync} from 'node:child_process';
|
||||
import {createServer} from 'node:net';
|
||||
import {
|
||||
getDefaultPostgresClient,
|
||||
type IPostgresClient,
|
||||
initPostgres,
|
||||
type PostgresQueryable,
|
||||
shutdownPostgres,
|
||||
} from '@pkgs/postgres/src/Client';
|
||||
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||
import {LegacyPostgresKvQueryExecutor} from './__testref__/LegacyPostgresKvQueryExecutor';
|
||||
import type {CassandraParams, KvQueryMeta, KvTableSpec, WhereExpr} from './CassandraTypes';
|
||||
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from './PostgresKvQueryExecutor';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type AnyMeta = KvQueryMeta<Row>;
|
||||
type AnyExec = LegacyPostgresKvQueryExecutor | PostgresKvQueryExecutor;
|
||||
|
||||
const KV = 'kv_pagead';
|
||||
const ICU_KV = 'kv_pagead_icu';
|
||||
const ICU_COLLATION = 'kvpagead_icu';
|
||||
const POSTGRES_IMAGE = 'postgres:16-alpine';
|
||||
const CONTAINER = `fluxer-kvpage-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
||||
|
||||
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
||||
|
||||
const PagedTable: KvTableSpec<Row> = {
|
||||
name: 'pagead_items',
|
||||
columns: ['owner_id', 'item_id', 'payload'],
|
||||
primaryKey: ['owner_id', 'item_id'],
|
||||
partitionKey: ['owner_id', 'item_id'],
|
||||
};
|
||||
|
||||
const FlatTable: KvTableSpec<Row> = {
|
||||
name: 'pagead_flat',
|
||||
columns: ['k', 'v'],
|
||||
primaryKey: ['k'],
|
||||
partitionKey: ['k'],
|
||||
};
|
||||
|
||||
class PlainClient implements IPostgresClient {
|
||||
constructor(
|
||||
private readonly inner: IPostgresClient,
|
||||
private readonly table: string,
|
||||
) {}
|
||||
async query<T extends Record<string, unknown>>(text: string, values: Array<unknown> = []) {
|
||||
return (await this.inner.query(text, values)) as unknown as Awaited<ReturnType<IPostgresClient['query']>> & {
|
||||
rows: Array<T>;
|
||||
};
|
||||
}
|
||||
async connect(): Promise<void> {
|
||||
await this.inner.connect();
|
||||
}
|
||||
async shutdown(): Promise<void> {}
|
||||
isConnected(): boolean {
|
||||
return this.inner.isConnected();
|
||||
}
|
||||
async transaction<T>(fn: (client: PostgresQueryable) => Promise<T>): Promise<T> {
|
||||
return this.inner.transaction(fn);
|
||||
}
|
||||
kvTable(): string {
|
||||
return this.table;
|
||||
}
|
||||
}
|
||||
|
||||
function selectMeta(spec: KvTableSpec<Row>, where: Array<WhereExpr<Row>> = [], extra: Partial<AnyMeta> = {}): AnyMeta {
|
||||
return {action: 'select', table: spec, where, columns: spec.columns, ...extra} as AnyMeta;
|
||||
}
|
||||
|
||||
const upsertMeta = (spec: KvTableSpec<Row>) => ({action: 'upsert', table: spec}) as AnyMeta;
|
||||
const deleteMeta = (spec: KvTableSpec<Row>, where: Array<WhereExpr<Row>>) =>
|
||||
({action: 'delete', table: spec, where}) as AnyMeta;
|
||||
|
||||
function token(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
}
|
||||
|
||||
describe.skipIf(!dockerAvailable)('postgres kv paging adversarial', () => {
|
||||
let raw: IPostgresClient;
|
||||
let legacy: LegacyPostgresKvQueryExecutor;
|
||||
let next: PostgresKvQueryExecutor;
|
||||
let icuNext: PostgresKvQueryExecutor;
|
||||
let icuLegacy: LegacyPostgresKvQueryExecutor;
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (typeof address === 'string' || address === null) {
|
||||
reject(new Error('no port'));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function upsert(exec: AnyExec, spec: KvTableSpec<Row>, row: Row) {
|
||||
await exec.executeQuery({cql: `__seed__${spec.name}`, params: row as CassandraParams, kvMeta: upsertMeta(spec)});
|
||||
}
|
||||
|
||||
async function wipe(kv: string) {
|
||||
await raw.query(`DELETE FROM ${kv}`);
|
||||
}
|
||||
|
||||
async function pageAll(
|
||||
exec: AnyExec,
|
||||
meta: AnyMeta,
|
||||
params: CassandraParams,
|
||||
pageSize: number,
|
||||
hook?: (pageIndex: number) => Promise<void>,
|
||||
): Promise<{pages: Array<Array<Row>>; error: string | null}> {
|
||||
const pages: Array<Array<Row>> = [];
|
||||
let pageState: string | null = null;
|
||||
try {
|
||||
for (let guard = 0; guard < 400; guard += 1) {
|
||||
const page: {rows: Array<Row>; pageState: string | null} = await exec.executePagedQuery<Row>(
|
||||
{cql: `__page__${meta.table.name}`, params, kvMeta: meta},
|
||||
{pageSize, pageState},
|
||||
);
|
||||
pages.push(page.rows);
|
||||
pageState = page.pageState;
|
||||
if (hook) await hook(guard);
|
||||
if (pageState === null) break;
|
||||
if (guard === 399) return {pages, error: 'NON_TERMINATING'};
|
||||
}
|
||||
} catch (error) {
|
||||
return {pages, error: (error as Error).message};
|
||||
}
|
||||
return {pages, error: null};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const port = await freePort();
|
||||
execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
CONTAINER,
|
||||
'-e',
|
||||
'POSTGRES_USER=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_PASSWORD=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_DB=fluxer',
|
||||
'-p',
|
||||
`127.0.0.1:${port}:5432`,
|
||||
POSTGRES_IMAGE,
|
||||
'-c',
|
||||
'fsync=off',
|
||||
'-c',
|
||||
'synchronous_commit=off',
|
||||
],
|
||||
{stdio: 'ignore'},
|
||||
);
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
|
||||
await sleep(500);
|
||||
const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (probe.status !== 0) continue;
|
||||
try {
|
||||
await initPostgres({url: `postgres://fluxer:[email protected]:${port}/fluxer`, maxConnections: 8});
|
||||
await getDefaultPostgresClient().query('SELECT 1');
|
||||
ready = true;
|
||||
} catch {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (!ready) throw new Error('postgres not ready');
|
||||
raw = getDefaultPostgresClient();
|
||||
const kvClient = new PlainClient(raw, KV);
|
||||
const icuClient = new PlainClient(raw, ICU_KV);
|
||||
await ensurePostgresKvSchema(kvClient);
|
||||
await raw.query(`CREATE COLLATION ${ICU_COLLATION} (provider = icu, locale = 'en-US')`);
|
||||
await ensurePostgresKvSchema(icuClient);
|
||||
for (const column of ['row_key', 'partition_key']) {
|
||||
await raw.query(`ALTER TABLE ${ICU_KV} ALTER COLUMN ${column} TYPE text COLLATE ${ICU_COLLATION}`);
|
||||
}
|
||||
legacy = new LegacyPostgresKvQueryExecutor(kvClient);
|
||||
next = new PostgresKvQueryExecutor(kvClient);
|
||||
icuLegacy = new LegacyPostgresKvQueryExecutor(icuClient);
|
||||
icuNext = new PostgresKvQueryExecutor(icuClient);
|
||||
}, 900_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
|
||||
it('pages a bare scan without skipping or repeating rows', async () => {
|
||||
await wipe(KV);
|
||||
for (let i = 0; i < 23; i += 1) await upsert(next, FlatTable, {k: BigInt(i), v: `v${i}`});
|
||||
for (const pageSize of [1, 2, 3, 5, 7, 23, 24, 100]) {
|
||||
const result = await pageAll(next, selectMeta(FlatTable), {}, pageSize);
|
||||
expect(result.error, `pageSize=${pageSize}`).toBeNull();
|
||||
const keys = result.pages.flat().map((row) => String(row.k));
|
||||
expect(new Set(keys).size, `dupes at pageSize=${pageSize}: ${keys.join(',')}`).toBe(23);
|
||||
expect(keys.length, `count at pageSize=${pageSize}`).toBe(23);
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
it('pages a prefix range without skipping or repeating rows', async () => {
|
||||
await wipe(KV);
|
||||
for (let owner = 0; owner < 3; owner += 1) {
|
||||
for (let i = 0; i < 17; i += 1) {
|
||||
await upsert(next, PagedTable, {owner_id: BigInt(owner), item_id: BigInt(i), payload: `p${owner}-${i}`});
|
||||
}
|
||||
}
|
||||
const meta = selectMeta(PagedTable, [{kind: 'eq', col: 'owner_id', param: 'owner_id'} as WhereExpr<Row>]);
|
||||
for (const pageSize of [1, 2, 4, 17, 18]) {
|
||||
const result = await pageAll(next, meta, {owner_id: 1n}, pageSize);
|
||||
expect(result.error).toBeNull();
|
||||
const rows = result.pages.flat();
|
||||
expect(rows.length, `pageSize=${pageSize}`).toBe(17);
|
||||
expect(new Set(rows.map((r) => String(r.item_id))).size).toBe(17);
|
||||
expect(rows.every((r) => String(r.owner_id) === '1')).toBe(true);
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
it('never loses a row that existed for the whole scan while rows are inserted between pages', async () => {
|
||||
await wipe(KV);
|
||||
const stable = new Set<string>();
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
await upsert(next, FlatTable, {k: BigInt(i), v: `v${i}`});
|
||||
stable.add(String(i));
|
||||
}
|
||||
let inserted = 100;
|
||||
const result = await pageAll(next, selectMeta(FlatTable), {}, 4, async () => {
|
||||
await upsert(next, FlatTable, {k: BigInt(inserted), v: `late${inserted}`});
|
||||
inserted += 1;
|
||||
});
|
||||
expect(result.error).toBeNull();
|
||||
const seen = result.pages.flat().map((r) => String(r.k));
|
||||
const dupes = seen.filter((key, index) => seen.indexOf(key) !== index);
|
||||
const missing = [...stable].filter((key) => !seen.includes(key));
|
||||
expect(missing, `missing stable rows: ${missing.join(',')}`).toEqual([]);
|
||||
expect(dupes, `duplicate rows: ${dupes.join(',')}`).toEqual([]);
|
||||
}, 300_000);
|
||||
|
||||
it('pages identically to the legacy executor while rows are deleted between pages', async () => {
|
||||
const run = async (exec: AnyExec) => {
|
||||
await wipe(KV);
|
||||
for (let i = 0; i < 30; i += 1) await upsert(exec, FlatTable, {k: BigInt(i), v: `v${i}`});
|
||||
let pageIndex = 0;
|
||||
const result = await pageAll(exec, selectMeta(FlatTable), {}, 4, async () => {
|
||||
const victim = pageIndex;
|
||||
pageIndex += 1;
|
||||
await exec.executeQuery({
|
||||
cql: '__del__',
|
||||
params: {k: BigInt(victim)},
|
||||
kvMeta: deleteMeta(FlatTable, [{kind: 'eq', col: 'k', param: 'k'} as WhereExpr<Row>]),
|
||||
});
|
||||
});
|
||||
return {
|
||||
error: result.error,
|
||||
pages: result.pages.map((page) => page.map((row) => String(row.k)).join(',')).join('|'),
|
||||
};
|
||||
};
|
||||
const legacyRun = await run(legacy);
|
||||
const nextRun = await run(next);
|
||||
expect(nextRun.error).toBeNull();
|
||||
expect(nextRun, `legacy=${legacyRun.pages} next=${nextRun.pages}`).toEqual(legacyRun);
|
||||
}, 300_000);
|
||||
|
||||
it('pages identically to the legacy executor when the last row of each page is deleted', async () => {
|
||||
const run = async (exec: AnyExec) => {
|
||||
await wipe(KV);
|
||||
for (let i = 0; i < 20; i += 1)
|
||||
await upsert(exec, FlatTable, {k: `key${String(i).padStart(2, '0')}`, v: `v${i}`});
|
||||
const meta = selectMeta(FlatTable);
|
||||
const seen: Array<string> = [];
|
||||
let pageState: string | null = null;
|
||||
for (let guard = 0; guard < 100; guard += 1) {
|
||||
const page: {rows: Array<Row>; pageState: string | null} = await exec.executePagedQuery<Row>(
|
||||
{cql: '__cur__', params: {}, kvMeta: meta},
|
||||
{pageSize: 3, pageState},
|
||||
);
|
||||
for (const row of page.rows) seen.push(String(row.k));
|
||||
const cursorRow = page.rows[page.rows.length - 1];
|
||||
pageState = page.pageState;
|
||||
if (cursorRow) {
|
||||
await exec.executeQuery({
|
||||
cql: '__delcur__',
|
||||
params: {k: cursorRow.k as string},
|
||||
kvMeta: deleteMeta(FlatTable, [{kind: 'eq', col: 'k', param: 'k'} as WhereExpr<Row>]),
|
||||
});
|
||||
}
|
||||
if (pageState === null) break;
|
||||
}
|
||||
return seen.join(',');
|
||||
};
|
||||
const legacySeen = await run(legacy);
|
||||
const nextSeen = await run(next);
|
||||
expect(nextSeen, `legacy=${legacySeen} next=${nextSeen}`).toBe(legacySeen);
|
||||
}, 300_000);
|
||||
|
||||
it('never returns an empty page together with a non-null page state', async () => {
|
||||
await wipe(KV);
|
||||
for (let owner = 0; owner < 4; owner += 1) {
|
||||
for (let i = 0; i < 11; i += 1) {
|
||||
await upsert(next, PagedTable, {owner_id: BigInt(owner), item_id: BigInt(i), payload: `p${owner}-${i}`});
|
||||
}
|
||||
}
|
||||
const shapes: Array<[string, AnyMeta, CassandraParams]> = [
|
||||
['scan', selectMeta(PagedTable), {}],
|
||||
[
|
||||
'eq-owner',
|
||||
selectMeta(PagedTable, [{kind: 'eq', col: 'owner_id', param: 'owner_id'} as WhereExpr<Row>]),
|
||||
{owner_id: 2n},
|
||||
],
|
||||
[
|
||||
'in-owner-single',
|
||||
selectMeta(PagedTable, [{kind: 'in', col: 'owner_id', param: 'owner_ids'} as WhereExpr<Row>]),
|
||||
{owner_ids: [2n]},
|
||||
],
|
||||
[
|
||||
'in-owner-multi',
|
||||
selectMeta(PagedTable, [{kind: 'in', col: 'owner_id', param: 'owner_ids'} as WhereExpr<Row>]),
|
||||
{owner_ids: [1n, 3n]},
|
||||
],
|
||||
[
|
||||
'in-owner-empty',
|
||||
selectMeta(PagedTable, [{kind: 'in', col: 'owner_id', param: 'owner_ids'} as WhereExpr<Row>]),
|
||||
{owner_ids: []},
|
||||
],
|
||||
];
|
||||
const offenders: Array<string> = [];
|
||||
for (const [name, meta, params] of shapes) {
|
||||
for (const pageSize of [1, 2, 3, 5, 11]) {
|
||||
let pageState: string | null = null;
|
||||
const flat: Array<Row> = [];
|
||||
for (let guard = 0; guard < 200; guard += 1) {
|
||||
const page: {rows: Array<Row>; pageState: string | null} = await next.executePagedQuery<Row>(
|
||||
{cql: `__empty__${name}`, params, kvMeta: meta},
|
||||
{pageSize, pageState},
|
||||
);
|
||||
if (page.rows.length === 0 && page.pageState !== null) {
|
||||
offenders.push(`${name}/size${pageSize} page ${guard}`);
|
||||
}
|
||||
flat.push(...page.rows);
|
||||
pageState = page.pageState;
|
||||
if (pageState === null) break;
|
||||
}
|
||||
const unpaged = await next.executeQuery<Row>({cql: `__whole__${name}`, params, kvMeta: meta});
|
||||
expect(flat.length, `${name}/size${pageSize} row count`).toBe(unpaged.length);
|
||||
const pagedKeys = flat.map((r) => `${String(r.owner_id)}:${String(r.item_id)}`).sort();
|
||||
const wholeKeys = unpaged.map((r) => `${String(r.owner_id)}:${String(r.item_id)}`).sort();
|
||||
expect(pagedKeys, `${name}/size${pageSize} multiset`).toEqual(wholeKeys);
|
||||
}
|
||||
}
|
||||
expect(offenders, `empty page with continuation: ${offenders.join(', ')}`).toEqual([]);
|
||||
}, 300_000);
|
||||
|
||||
it('matches legacy throw/no-throw behaviour for hand-crafted and corrupt page states', async () => {
|
||||
await wipe(KV);
|
||||
for (let i = 0; i < 9; i += 1) await upsert(next, FlatTable, {k: BigInt(i), v: `v${i}`});
|
||||
const meta = selectMeta(FlatTable);
|
||||
const query = {cql: '__tok__', params: {}, kvMeta: meta};
|
||||
const tokens: Array<[string, string]> = [
|
||||
['empty-object', token({})],
|
||||
['number', token(123)],
|
||||
['string', token('hello')],
|
||||
['array', token([1, 2])],
|
||||
['unknown-key', token({foo: 1})],
|
||||
['offset-negative', token({offset: -1})],
|
||||
['offset-fractional', token({offset: 1.5})],
|
||||
['offset-string', token({offset: '2'})],
|
||||
['offset-huge', token({offset: Number.MAX_SAFE_INTEGER})],
|
||||
['after-number', token({after: 5})],
|
||||
['after-null', token({after: null})],
|
||||
['after-object', token({after: {a: 1}})],
|
||||
['after-and-offset', token({after: 'x', offset: 3})],
|
||||
['garbage', 'not-base64-at-all'],
|
||||
];
|
||||
const deltas: Array<string> = [];
|
||||
for (const [name, value] of tokens) {
|
||||
const legacyResult = await legacy
|
||||
.executePagedQuery<Row>(query, {pageSize: 3, pageState: value})
|
||||
.then((r) => ({err: null as string | null, rows: r.rows.length}))
|
||||
.catch((e: Error) => ({err: e.message, rows: -1}));
|
||||
const nextResult = await next
|
||||
.executePagedQuery<Row>(query, {pageSize: 3, pageState: value})
|
||||
.then((r) => ({err: null as string | null, rows: r.rows.length}))
|
||||
.catch((e: Error) => ({err: e.message, rows: -1}));
|
||||
if ((legacyResult.err === null) !== (nextResult.err === null)) {
|
||||
deltas.push(
|
||||
`${name}: legacy=${legacyResult.err ?? `ok(${legacyResult.rows})`} next=${nextResult.err ?? `ok(${nextResult.rows})`}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(deltas, `page-state error behaviour deltas:\n${deltas.join('\n')}`).toEqual([]);
|
||||
}, 300_000);
|
||||
|
||||
it('matches legacy behaviour for degenerate page sizes', async () => {
|
||||
await wipe(KV);
|
||||
for (let i = 0; i < 9; i += 1) await upsert(next, FlatTable, {k: BigInt(i), v: `v${i}`});
|
||||
const query = {cql: '__ps__', params: {}, kvMeta: selectMeta(FlatTable)};
|
||||
const deltas: Array<string> = [];
|
||||
for (const pageSize of [0, -1, 1.5, 2.9, Number.MAX_SAFE_INTEGER, 2 ** 31, Number.NaN]) {
|
||||
const l = await legacy
|
||||
.executePagedQuery<Row>(query, {pageSize})
|
||||
.then((r) => `ok rows=${r.rows.length} more=${r.pageState !== null}`)
|
||||
.catch((e: Error) => `throw ${e.message}`);
|
||||
const n = await next
|
||||
.executePagedQuery<Row>(query, {pageSize})
|
||||
.then((r) => `ok rows=${r.rows.length} more=${r.pageState !== null}`)
|
||||
.catch((e: Error) => `throw ${e.message}`);
|
||||
if (l !== n) deltas.push(`pageSize=${String(pageSize)}: legacy=${l} next=${n}`);
|
||||
}
|
||||
expect(deltas, `page size deltas:\n${deltas.join('\n')}`).toEqual([]);
|
||||
}, 300_000);
|
||||
|
||||
it('does not silently truncate when a page token is reused across a different query', async () => {
|
||||
await wipe(KV);
|
||||
for (let owner = 0; owner < 3; owner += 1) {
|
||||
for (let i = 0; i < 9; i += 1) {
|
||||
await upsert(next, PagedTable, {owner_id: BigInt(owner), item_id: BigInt(i), payload: `p${owner}-${i}`});
|
||||
}
|
||||
}
|
||||
const meta = selectMeta(PagedTable, [{kind: 'eq', col: 'owner_id', param: 'owner_id'} as WhereExpr<Row>]);
|
||||
const firstNext = await next.executePagedQuery<Row>(
|
||||
{cql: '__x__', params: {owner_id: 0n}, kvMeta: meta},
|
||||
{pageSize: 3},
|
||||
);
|
||||
const firstLegacy = await legacy.executePagedQuery<Row>(
|
||||
{cql: '__x__', params: {owner_id: 0n}, kvMeta: meta},
|
||||
{pageSize: 3},
|
||||
);
|
||||
expect(firstNext.pageState).not.toBeNull();
|
||||
const crossNext = await next.executePagedQuery<Row>(
|
||||
{cql: '__x__', params: {owner_id: 2n}, kvMeta: meta},
|
||||
{pageSize: 3, pageState: firstNext.pageState},
|
||||
);
|
||||
const crossLegacy = await legacy.executePagedQuery<Row>(
|
||||
{cql: '__x__', params: {owner_id: 2n}, kvMeta: meta},
|
||||
{pageSize: 3, pageState: firstLegacy.pageState},
|
||||
);
|
||||
expect(crossNext.rows.length, 'cross-query token row count diverges from legacy').toBe(crossLegacy.rows.length);
|
||||
}, 300_000);
|
||||
|
||||
it('does not restart the scan when a keyset token reaches a non-keyset plan', async () => {
|
||||
await wipe(KV);
|
||||
for (let owner = 0; owner < 3; owner += 1) {
|
||||
for (let i = 0; i < 9; i += 1) {
|
||||
await upsert(next, PagedTable, {owner_id: BigInt(owner), item_id: BigInt(i), payload: `p${owner}-${i}`});
|
||||
}
|
||||
}
|
||||
const inMeta = selectMeta(PagedTable, [{kind: 'in', col: 'owner_id', param: 'owner_ids'} as WhereExpr<Row>]);
|
||||
const first = await next.executePagedQuery<Row>(
|
||||
{cql: '__flip__', params: {owner_ids: [1n]}, kvMeta: inMeta},
|
||||
{pageSize: 3},
|
||||
);
|
||||
expect(first.pageState).not.toBeNull();
|
||||
const flipped = await next.executePagedQuery<Row>(
|
||||
{cql: '__flip__', params: {owner_ids: [1n, 2n]}, kvMeta: inMeta},
|
||||
{pageSize: 3, pageState: first.pageState},
|
||||
);
|
||||
const firstKeys = first.rows.map((r) => String(r.item_id));
|
||||
const flippedKeys = flipped.rows.map((r) => String(r.item_id));
|
||||
const overlap = flippedKeys.filter((key) => firstKeys.includes(key));
|
||||
expect(overlap, `keyset token silently restarted the scan: first=${firstKeys} flipped=${flippedKeys}`).toEqual([]);
|
||||
}, 300_000);
|
||||
|
||||
it('pages correctly on a database whose row_key column has a linguistic collation', async () => {
|
||||
await wipe(ICU_KV);
|
||||
const keys = ['a', 'A', 'a b', 'ab', 'B', 'b', '\u{1f600}', 'z', 'Z', '', '"', '\\', 'ab'];
|
||||
for (const key of keys) await upsert(icuNext, FlatTable, {k: key, v: `v:${key}`});
|
||||
for (const pageSize of [1, 2, 3, 5]) {
|
||||
const result = await pageAll(icuNext, selectMeta(FlatTable), {}, pageSize);
|
||||
expect(result.error, `icu pageSize=${pageSize}`).toBeNull();
|
||||
const seen = result.pages.flat().map((r) => String(r.k));
|
||||
const dupes = seen.filter((key, index) => seen.indexOf(key) !== index);
|
||||
expect(dupes, `icu dupes pageSize=${pageSize}: ${JSON.stringify(dupes)}`).toEqual([]);
|
||||
expect(seen.length, `icu count pageSize=${pageSize}`).toBe(keys.length);
|
||||
}
|
||||
const legacyAll = await pageAll(icuLegacy, selectMeta(FlatTable), {}, 3);
|
||||
expect(legacyAll.pages.flat().length).toBe(keys.length);
|
||||
}, 300_000);
|
||||
|
||||
it('reports the paged order delta against legacy for key shapes that reach real callers', async () => {
|
||||
const DatedTable: KvTableSpec<Row> = {
|
||||
name: 'pagead_dated',
|
||||
columns: ['customer_id', 'created_at', 'provider_id', 'total'],
|
||||
primaryKey: ['customer_id', 'created_at', 'provider_id'],
|
||||
partitionKey: ['customer_id'],
|
||||
};
|
||||
const NumericTable: KvTableSpec<Row> = {
|
||||
name: 'pagead_numeric',
|
||||
columns: ['owner_id', 'seq', 'payload'],
|
||||
primaryKey: ['owner_id', 'seq'],
|
||||
partitionKey: ['owner_id'],
|
||||
};
|
||||
await wipe(KV);
|
||||
for (let i = 0; i < 15; i += 1) {
|
||||
await upsert(next, DatedTable, {
|
||||
customer_id: 'cus_1',
|
||||
created_at: new Date(Date.UTC(2024, 0, 1 + i, i, i)),
|
||||
provider_id: `in_${String(i).padStart(3, '0')}`,
|
||||
total: BigInt(i),
|
||||
});
|
||||
await upsert(next, NumericTable, {owner_id: 7n, seq: BigInt(i), payload: `p${i}`});
|
||||
}
|
||||
const datedMeta = selectMeta(DatedTable, [
|
||||
{kind: 'eq', col: 'customer_id', param: 'customer_id'} as WhereExpr<Row>,
|
||||
]);
|
||||
const numericMeta = selectMeta(NumericTable, [{kind: 'eq', col: 'owner_id', param: 'owner_id'} as WhereExpr<Row>]);
|
||||
const datedLegacy = await pageAll(legacy, datedMeta, {customer_id: 'cus_1'}, 4);
|
||||
const datedNext = await pageAll(next, datedMeta, {customer_id: 'cus_1'}, 4);
|
||||
const numericLegacy = await pageAll(legacy, numericMeta, {owner_id: 7n}, 4);
|
||||
const numericNext = await pageAll(next, numericMeta, {owner_id: 7n}, 4);
|
||||
const datedLegacyOrder = datedLegacy.pages.flat().map((r) => String(r.provider_id));
|
||||
const datedNextOrder = datedNext.pages.flat().map((r) => String(r.provider_id));
|
||||
const numericLegacyOrder = numericLegacy.pages.flat().map((r) => String(r.seq));
|
||||
const numericNextOrder = numericNext.pages.flat().map((r) => String(r.seq));
|
||||
const deltas: Array<string> = [];
|
||||
if (datedLegacyOrder.join(',') !== datedNextOrder.join(',')) {
|
||||
deltas.push(`date-keyed: legacy=${datedLegacyOrder.join(',')} next=${datedNextOrder.join(',')}`);
|
||||
}
|
||||
if (numericLegacyOrder.join(',') !== numericNextOrder.join(',')) {
|
||||
deltas.push(`bigint-keyed: legacy=${numericLegacyOrder.join(',')} next=${numericNextOrder.join(',')}`);
|
||||
}
|
||||
expect(datedNextOrder.slice().sort(), 'date-keyed row set').toEqual(datedLegacyOrder.slice().sort());
|
||||
expect(numericNextOrder.slice().sort(), 'bigint-keyed row set').toEqual(numericLegacyOrder.slice().sort());
|
||||
expect(deltas, `paged order deltas:\n${deltas.join('\n')}`).toEqual([]);
|
||||
}, 300_000);
|
||||
|
||||
it('pages a prefix range whose keys sit adjacent to the range bounds', async () => {
|
||||
await wipe(KV);
|
||||
const owners = ['a', 'a b', 'ab', 'a', 'a"'];
|
||||
for (const owner of owners) {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await upsert(next, PagedTable, {owner_id: owner, item_id: BigInt(i), payload: `${owner}#${i}`});
|
||||
}
|
||||
}
|
||||
const meta = selectMeta(PagedTable, [{kind: 'eq', col: 'owner_id', param: 'owner_id'} as WhereExpr<Row>]);
|
||||
for (const owner of owners) {
|
||||
for (const pageSize of [1, 2, 5]) {
|
||||
const result = await pageAll(next, meta, {owner_id: owner}, pageSize);
|
||||
expect(result.error).toBeNull();
|
||||
const rows = result.pages.flat();
|
||||
expect(rows.length, `owner=${JSON.stringify(owner)} pageSize=${pageSize}`).toBe(5);
|
||||
expect(
|
||||
rows.every((r) => r.owner_id === owner),
|
||||
`owner leak for ${JSON.stringify(owner)}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,14 @@
|
||||
|
||||
import {type IPostgresClient, type PostgresQueryable, quoteIdentifier} from '@pkgs/postgres/src/Client';
|
||||
import cassandra from 'cassandra-driver';
|
||||
import {Logger} from '../Logger';
|
||||
import {getKvMeta, getTableMetadata} from './CassandraMetaRegistry';
|
||||
import type {CassandraParams, ColumnName, KvQueryMeta, PreparedQuery, WhereExpr} from './CassandraTypes';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type EqWhereExpr = Extract<WhereExpr<Row>, {kind: 'eq'}>;
|
||||
type InWhereExpr = Extract<WhereExpr<Row>, {kind: 'in'}>;
|
||||
type PinnedWhereExpr = EqWhereExpr | InWhereExpr;
|
||||
|
||||
interface StoredRow {
|
||||
row_key: string;
|
||||
@@ -17,7 +20,40 @@ interface PageState {
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export type CandidatePlan =
|
||||
| {kind: 'none'}
|
||||
| {kind: 'rowKeys'; rowKeys: Array<string>}
|
||||
| {kind: 'range'; lowerBound: string; upperBound: string}
|
||||
| {kind: 'ranges'; lowerBounds: Array<string>; upperBounds: Array<string>}
|
||||
| {kind: 'partitionKeys'; partitionKeys: Array<string>}
|
||||
| {kind: 'scan'};
|
||||
|
||||
export interface QueryPlan {
|
||||
candidates: CandidatePlan;
|
||||
exact: boolean;
|
||||
}
|
||||
|
||||
interface QueryShape {
|
||||
leadingClauses: ReadonlyArray<PinnedWhereExpr>;
|
||||
partitionClauses: ReadonlyArray<PinnedWhereExpr> | null;
|
||||
inClauses: ReadonlyArray<InWhereExpr>;
|
||||
whereColumns: ReadonlyArray<string>;
|
||||
requiredColumns: ReadonlyArray<string> | null;
|
||||
clauseCount: number;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface PlanFragments {
|
||||
predicate: string;
|
||||
params: Array<unknown>;
|
||||
}
|
||||
|
||||
const VALUE_SEPARATOR = '\u001f';
|
||||
const KEY_RANGE_UPPER = ' ';
|
||||
const MAX_ROW_KEY_COMBINATIONS = 32_768;
|
||||
const MAX_PREFIX_RANGES = 256;
|
||||
const FULL_SCAN_LOG_INTERVAL_MS = 60_000;
|
||||
const FULL_SCAN_LOG_KEY_LIMIT = 1024;
|
||||
const ENCODED_TYPE_KEY = '__fluxer_type';
|
||||
const POSTGRES_KV_SCHEMA_LOCK_NAMESPACE = 0x46584b56;
|
||||
const POSTGRES_KV_SCHEMA_LOCK_TIMEOUT = '120s';
|
||||
@@ -93,11 +129,24 @@ function decodeRow(value: unknown): Row {
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function decodeRowColumns(value: unknown, columns: ReadonlyArray<string>): Row {
|
||||
if (!isPlainObject(value)) {
|
||||
throw new Error('Postgres KV row payload is not an object');
|
||||
}
|
||||
const decoded: Row = {};
|
||||
for (const column of columns) {
|
||||
if (column in value) {
|
||||
decoded[column] = decodeValue(value[column]);
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function valueKey(value: unknown): string {
|
||||
return JSON.stringify(encodeValue(value));
|
||||
}
|
||||
|
||||
function keyFromColumns(columns: ReadonlyArray<string>, row: Row): string {
|
||||
export function keyFromColumns(columns: ReadonlyArray<string>, row: Row): string {
|
||||
return columns.map((column) => valueKey(row[column])).join(VALUE_SEPARATOR);
|
||||
}
|
||||
|
||||
@@ -137,10 +186,6 @@ function rowKeyFromParams(meta: KvQueryMeta, params: CassandraParams): string {
|
||||
return rowKey(meta, paramsRow(params, (meta.pkColumns ?? meta.table.primaryKey) as ReadonlyArray<string>));
|
||||
}
|
||||
|
||||
function partitionKeyFromParams(meta: KvQueryMeta, params: CassandraParams): string {
|
||||
return partitionKey(meta, paramsRow(params, meta.table.partitionKey as ReadonlyArray<string>));
|
||||
}
|
||||
|
||||
function compareValues(left: unknown, right: unknown): number {
|
||||
if (typeof left === 'bigint' || typeof right === 'bigint') {
|
||||
const l = typeof left === 'bigint' ? left : BigInt(left as number | string);
|
||||
@@ -160,8 +205,8 @@ function valuesEqual(left: unknown, right: unknown): boolean {
|
||||
if (left == null && right == null) return true;
|
||||
if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
|
||||
if (Buffer.isBuffer(left) && Buffer.isBuffer(right)) return left.equals(right);
|
||||
if (left?.constructor?.name === 'LocalDate' || right?.constructor?.name === 'LocalDate') {
|
||||
return left?.toString() === right?.toString();
|
||||
if (left?.constructor?.name === 'LocalDate' && right?.constructor?.name === 'LocalDate') {
|
||||
return left.toString() === right.toString();
|
||||
}
|
||||
return left === right;
|
||||
}
|
||||
@@ -170,7 +215,11 @@ function getParam(params: CassandraParams, param: string): unknown {
|
||||
return params[param];
|
||||
}
|
||||
|
||||
function matchesWhere(row: Row, where: ReadonlyArray<WhereExpr<Row>> | undefined, params: CassandraParams): boolean {
|
||||
export function matchesWhere(
|
||||
row: Row,
|
||||
where: ReadonlyArray<WhereExpr<Row>> | undefined,
|
||||
params: CassandraParams,
|
||||
): boolean {
|
||||
for (const clause of where ?? []) {
|
||||
switch (clause.kind) {
|
||||
case 'eq':
|
||||
@@ -238,39 +287,269 @@ function sortRows(meta: KvQueryMeta, rows: Array<Row>): Array<Row> {
|
||||
});
|
||||
}
|
||||
|
||||
function equalityParam(where: ReadonlyArray<WhereExpr<Row>> | undefined, column: string): string | null {
|
||||
const clause = (where ?? []).find((entry) => entry.kind === 'eq' && entry.col === column);
|
||||
return clause && clause.kind === 'eq' ? clause.param : null;
|
||||
function whereClauses(meta: KvQueryMeta): ReadonlyArray<WhereExpr<Row>> {
|
||||
return (meta.where ?? []) as ReadonlyArray<WhereExpr<Row>>;
|
||||
}
|
||||
|
||||
function inParam(where: ReadonlyArray<WhereExpr<Row>> | undefined, column: string): string | null {
|
||||
const clause = (where ?? []).find((entry) => entry.kind === 'in' && entry.col === column);
|
||||
return clause && clause.kind === 'in' ? clause.param : null;
|
||||
}
|
||||
|
||||
function fullRowKeysFromWhere(meta: KvQueryMeta, params: CassandraParams): Array<string> | null {
|
||||
const pk = meta.table.primaryKey as ReadonlyArray<string>;
|
||||
const eqParams = pk.map((column) => equalityParam(meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, column));
|
||||
if (eqParams.every((param) => param !== null)) {
|
||||
const row: Row = {};
|
||||
for (let i = 0; i < pk.length; i += 1) row[pk[i]!] = params[eqParams[i]!];
|
||||
return [rowKey(meta, row)];
|
||||
}
|
||||
if (pk.length === 1) {
|
||||
const param = inParam(meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, pk[0]!);
|
||||
if (param) {
|
||||
const values = params[param] as ReadonlyArray<unknown> | Set<unknown>;
|
||||
const haystack = values instanceof Set ? [...values] : values;
|
||||
return haystack.map((value) => rowKey(meta, {[pk[0]!]: value}));
|
||||
}
|
||||
function pinnedClause(where: ReadonlyArray<WhereExpr<Row>>, column: string): PinnedWhereExpr | null {
|
||||
for (const clause of where) {
|
||||
if ((clause.kind === 'eq' || clause.kind === 'in') && clause.col === column) return clause;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasFullPartition(meta: KvQueryMeta): boolean {
|
||||
return meta.table.partitionKey.every((column) =>
|
||||
equalityParam(meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, column),
|
||||
);
|
||||
function clauseColumns(clause: WhereExpr<Row>): ReadonlyArray<string> {
|
||||
return clause.kind === 'tupleGt' ? (clause.cols as ReadonlyArray<string>) : [clause.col as string];
|
||||
}
|
||||
|
||||
function describeClause(clause: WhereExpr<Row>): string {
|
||||
return `${clauseColumns(clause).join('+')} ${clause.kind}`;
|
||||
}
|
||||
|
||||
function requiredColumnsFor(meta: KvQueryMeta, whereColumns: ReadonlyArray<string>): ReadonlyArray<string> | null {
|
||||
if (!meta.columns) return null;
|
||||
const required = new Set<string>(meta.columns as ReadonlyArray<string>);
|
||||
for (const column of whereColumns) required.add(column);
|
||||
if (meta.orderBy) required.add(meta.orderBy.col as string);
|
||||
for (const column of meta.table.primaryKey as ReadonlyArray<string>) required.add(column);
|
||||
for (const column of meta.table.columns as ReadonlyArray<string>) {
|
||||
if (!required.has(column)) return [...required];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const QUERY_SHAPES = new WeakMap<KvQueryMeta, QueryShape>();
|
||||
|
||||
function queryShape(meta: KvQueryMeta): QueryShape {
|
||||
const cached = QUERY_SHAPES.get(meta);
|
||||
if (cached) return cached;
|
||||
const where = whereClauses(meta);
|
||||
const leadingClauses: Array<PinnedWhereExpr> = [];
|
||||
for (const column of meta.table.primaryKey as ReadonlyArray<string>) {
|
||||
const clause = pinnedClause(where, column);
|
||||
if (!clause) break;
|
||||
leadingClauses.push(clause);
|
||||
}
|
||||
const partitionColumns = meta.table.partitionKey as ReadonlyArray<string>;
|
||||
const partitionClauses: Array<PinnedWhereExpr> = [];
|
||||
for (const column of partitionColumns) {
|
||||
const clause = pinnedClause(where, column);
|
||||
if (!clause) {
|
||||
partitionClauses.length = 0;
|
||||
break;
|
||||
}
|
||||
partitionClauses.push(clause);
|
||||
}
|
||||
const whereColumns = [...new Set(where.flatMap(clauseColumns))];
|
||||
const shape: QueryShape = {
|
||||
leadingClauses,
|
||||
partitionClauses:
|
||||
partitionColumns.length > 0 && partitionClauses.length === partitionColumns.length ? partitionClauses : null,
|
||||
inClauses: where.filter((clause): clause is InWhereExpr => clause.kind === 'in'),
|
||||
whereColumns,
|
||||
requiredColumns: requiredColumnsFor(meta, whereColumns),
|
||||
clauseCount: where.length,
|
||||
summary: where.map(describeClause).join(', '),
|
||||
};
|
||||
QUERY_SHAPES.set(meta, shape);
|
||||
return shape;
|
||||
}
|
||||
|
||||
function clauseValues(clause: PinnedWhereExpr, params: CassandraParams): Array<unknown> | null {
|
||||
if (clause.kind === 'eq') return [getParam(params, clause.param)];
|
||||
const values = getParam(params, clause.param);
|
||||
if (values === null || values === undefined) return [];
|
||||
if (values instanceof Set) return [...values];
|
||||
if (Array.isArray(values)) return [...values];
|
||||
return null;
|
||||
}
|
||||
|
||||
function isKeyComparableValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'bigint') return true;
|
||||
if (typeof value === 'number') return Number.isFinite(value);
|
||||
if (Buffer.isBuffer(value)) return true;
|
||||
if (value instanceof Date) return !Number.isNaN(value.getTime());
|
||||
return false;
|
||||
}
|
||||
|
||||
function keySegments(values: ReadonlyArray<unknown>): Array<string> | null {
|
||||
const segments: Array<string> = [];
|
||||
for (const value of values) {
|
||||
let segment: string;
|
||||
try {
|
||||
segment = valueKey(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof segment !== 'string') return null;
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function combinationCount(segmentLists: ReadonlyArray<ReadonlyArray<string>>): number {
|
||||
let total = 1;
|
||||
for (const segments of segmentLists) total *= segments.length;
|
||||
return total;
|
||||
}
|
||||
|
||||
function keyPrefixes(segmentLists: ReadonlyArray<ReadonlyArray<string>>): Array<string> {
|
||||
let prefixes: Array<string> | null = null;
|
||||
for (const segments of segmentLists) {
|
||||
const next: Array<string> = [];
|
||||
for (const segment of segments) {
|
||||
if (prefixes === null) {
|
||||
next.push(segment);
|
||||
continue;
|
||||
}
|
||||
for (const prefix of prefixes) next.push(`${prefix}${VALUE_SEPARATOR}${segment}`);
|
||||
}
|
||||
prefixes = next;
|
||||
}
|
||||
return [...new Set(prefixes ?? [])];
|
||||
}
|
||||
|
||||
function keyRangeLowerBound(prefix: string): string {
|
||||
return `${prefix}${VALUE_SEPARATOR}`;
|
||||
}
|
||||
|
||||
function keyRangeUpperBound(prefix: string): string {
|
||||
return `${prefix}${KEY_RANGE_UPPER}`;
|
||||
}
|
||||
|
||||
interface PinnedColumn {
|
||||
values: Array<unknown>;
|
||||
segments: Array<string>;
|
||||
}
|
||||
|
||||
function pinnedColumns(clauses: ReadonlyArray<PinnedWhereExpr>, params: CassandraParams): Array<PinnedColumn> {
|
||||
const pinned: Array<PinnedColumn> = [];
|
||||
for (const clause of clauses) {
|
||||
const values = clauseValues(clause, params);
|
||||
if (values === null || values.length === 0) break;
|
||||
const segments = keySegments(values);
|
||||
if (segments === null) break;
|
||||
pinned.push({values, segments});
|
||||
}
|
||||
return pinned;
|
||||
}
|
||||
|
||||
function planIsExact(meta: KvQueryMeta, shape: QueryShape, pinned: ReadonlyArray<PinnedColumn>): boolean {
|
||||
if (pinned.length !== shape.clauseCount) return false;
|
||||
if (meta.limit !== undefined || meta.orderBy !== undefined) return false;
|
||||
for (const column of pinned) {
|
||||
for (const value of column.values) {
|
||||
if (!isKeyComparableValue(value)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildCandidatePlan(meta: KvQueryMeta, params: CassandraParams): QueryPlan {
|
||||
const shape = queryShape(meta);
|
||||
for (const clause of shape.inClauses) {
|
||||
const values = clauseValues(clause, params);
|
||||
if (values !== null && values.length === 0) {
|
||||
return {candidates: {kind: 'none'}, exact: true};
|
||||
}
|
||||
}
|
||||
const primaryKey = meta.table.primaryKey as ReadonlyArray<string>;
|
||||
const pinned = pinnedColumns(shape.leadingClauses, params);
|
||||
let leading = pinned.length;
|
||||
while (leading > 0) {
|
||||
const segmentLists = pinned.slice(0, leading).map((column) => column.segments);
|
||||
const cap = leading === primaryKey.length ? MAX_ROW_KEY_COMBINATIONS : MAX_PREFIX_RANGES;
|
||||
if (combinationCount(segmentLists) <= cap) break;
|
||||
leading -= 1;
|
||||
}
|
||||
if (leading > 0) {
|
||||
const columns = pinned.slice(0, leading);
|
||||
const prefixes = keyPrefixes(columns.map((column) => column.segments));
|
||||
const exact = planIsExact(meta, shape, columns);
|
||||
if (leading === primaryKey.length) {
|
||||
return {candidates: {kind: 'rowKeys', rowKeys: prefixes}, exact};
|
||||
}
|
||||
if (prefixes.length === 1) {
|
||||
const prefix = prefixes[0]!;
|
||||
return {
|
||||
candidates: {kind: 'range', lowerBound: keyRangeLowerBound(prefix), upperBound: keyRangeUpperBound(prefix)},
|
||||
exact,
|
||||
};
|
||||
}
|
||||
return {
|
||||
candidates: {
|
||||
kind: 'ranges',
|
||||
lowerBounds: prefixes.map(keyRangeLowerBound),
|
||||
upperBounds: prefixes.map(keyRangeUpperBound),
|
||||
},
|
||||
exact,
|
||||
};
|
||||
}
|
||||
if (shape.partitionClauses) {
|
||||
const partition = pinnedColumns(shape.partitionClauses, params);
|
||||
if (
|
||||
partition.length === shape.partitionClauses.length &&
|
||||
combinationCount(partition.map((column) => column.segments)) <= MAX_ROW_KEY_COMBINATIONS
|
||||
) {
|
||||
return {
|
||||
candidates: {kind: 'partitionKeys', partitionKeys: keyPrefixes(partition.map((column) => column.segments))},
|
||||
exact: planIsExact(meta, shape, partition),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {candidates: {kind: 'scan'}, exact: planIsExact(meta, shape, [])};
|
||||
}
|
||||
|
||||
export function planFragments(plan: CandidatePlan): PlanFragments {
|
||||
switch (plan.kind) {
|
||||
case 'rowKeys':
|
||||
return {predicate: ' AND kv.row_key = ANY($2::text[])', params: [plan.rowKeys]};
|
||||
case 'range':
|
||||
return {
|
||||
predicate: ' AND kv.row_key COLLATE "C" >= $2 AND kv.row_key COLLATE "C" < $3',
|
||||
params: [plan.lowerBound, plan.upperBound],
|
||||
};
|
||||
case 'ranges': {
|
||||
const params: Array<unknown> = [];
|
||||
const arms = plan.lowerBounds.map((lowerBound, index) => {
|
||||
params.push(lowerBound, plan.upperBounds[index]);
|
||||
return `(kv.row_key COLLATE "C" >= $${params.length} AND kv.row_key COLLATE "C" < $${params.length + 1})`;
|
||||
});
|
||||
return {predicate: ` AND (${arms.join(' OR ')})`, params};
|
||||
}
|
||||
case 'partitionKeys':
|
||||
return plan.partitionKeys.length === 1
|
||||
? {predicate: ' AND kv.partition_key = $2', params: [plan.partitionKeys[0]]}
|
||||
: {predicate: ' AND kv.partition_key = ANY($2::text[])', params: [plan.partitionKeys]};
|
||||
default:
|
||||
return {predicate: '', params: []};
|
||||
}
|
||||
}
|
||||
|
||||
function logWarn(details: Record<string, unknown>, message: string): void {
|
||||
try {
|
||||
Logger.warn(details, message);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function logError(details: Record<string, unknown>, message: string): void {
|
||||
try {
|
||||
Logger.error(details, message);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const fullScanLoggedAt = new Map<string, number>();
|
||||
|
||||
function logFullScan(meta: KvQueryMeta): void {
|
||||
const shape = queryShape(meta);
|
||||
const key = `${meta.table.name}|${meta.action}|${shape.summary}`;
|
||||
const now = Date.now();
|
||||
const last = fullScanLoggedAt.get(key);
|
||||
if (last !== undefined && now - last < FULL_SCAN_LOG_INTERVAL_MS) return;
|
||||
if (fullScanLoggedAt.size >= FULL_SCAN_LOG_KEY_LIMIT) fullScanLoggedAt.clear();
|
||||
fullScanLoggedAt.set(key, now);
|
||||
logWarn({table: meta.table.name, action: meta.action, where: shape.summary || 'none'}, 'Postgres KV full table scan');
|
||||
}
|
||||
|
||||
function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | null | undefined {
|
||||
@@ -355,6 +634,24 @@ function parseEqWhere(whereSql: string, cql: string): ReadonlyArray<EqWhereExpr>
|
||||
});
|
||||
}
|
||||
|
||||
async function repairInvalidPostgresKvIndexes(db: PostgresQueryable, kvTable: string): Promise<void> {
|
||||
const invalid = await db.query<{index_name: string; index_def: string}>(
|
||||
`SELECT cls.relname AS index_name, pg_get_indexdef(idx.indexrelid) AS index_def
|
||||
FROM pg_index idx
|
||||
JOIN pg_class cls ON cls.oid = idx.indexrelid
|
||||
WHERE idx.indrelid = to_regclass($1)
|
||||
AND NOT idx.indisvalid
|
||||
AND NOT idx.indisprimary
|
||||
AND NOT idx.indisunique`,
|
||||
[kvTable],
|
||||
);
|
||||
for (const row of invalid.rows) {
|
||||
logError({table: kvTable, index: row.index_name}, 'Postgres KV index is invalid, rebuilding it');
|
||||
await db.query(`DROP INDEX IF EXISTS ${quoteIdentifier(row.index_name)}`);
|
||||
await db.query(row.index_def);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensurePostgresKvSchema(client: IPostgresClient): Promise<void> {
|
||||
const kvTable = client.kvTable();
|
||||
const table = quoteIdentifier(kvTable);
|
||||
@@ -387,12 +684,22 @@ CREATE TABLE IF NOT EXISTS ${table} (
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_message_reactions_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'message_reactions'`,
|
||||
);
|
||||
await db.query(`
|
||||
await repairInvalidPostgresKvIndexes(db, kvTable);
|
||||
const pending = await db.query(`
|
||||
SELECT 1
|
||||
FROM ${table}
|
||||
WHERE table_name = 'messages'
|
||||
AND partition_key = row_key
|
||||
AND split_part(row_key, chr(31), 3) <> ''
|
||||
LIMIT 1`);
|
||||
if (pending.rows.length > 0) {
|
||||
await db.query(`
|
||||
UPDATE ${table}
|
||||
SET partition_key = split_part(row_key, chr(31), 1) || chr(31) || split_part(row_key, chr(31), 2)
|
||||
WHERE table_name = 'messages'
|
||||
AND partition_key = row_key
|
||||
AND split_part(row_key, chr(31), 3) <> ''`);
|
||||
}
|
||||
await db.query(`DROP INDEX IF EXISTS ${quoteIdentifier(`${kvTable}_partition_idx`)}`);
|
||||
});
|
||||
}
|
||||
@@ -434,9 +741,9 @@ export class PostgresKvQueryExecutor {
|
||||
const meta = this.meta(query);
|
||||
switch (meta.action) {
|
||||
case 'select':
|
||||
return (await this.select(meta, query.params, db)) as Array<T>;
|
||||
return (await this.select(meta, query.params, buildCandidatePlan(meta, query.params), db)) as Array<T>;
|
||||
case 'count':
|
||||
return [{count: (await this.select(meta, query.params, db)).length}] as Array<T>;
|
||||
return (await this.count(meta, query.params, db)) as Array<T>;
|
||||
case 'upsert':
|
||||
return (await this.upsert(meta, query.params, db)) as Array<T>;
|
||||
case 'insert':
|
||||
@@ -495,42 +802,51 @@ export class PostgresKvQueryExecutor {
|
||||
return meta;
|
||||
}
|
||||
|
||||
private async candidates(
|
||||
meta: KvQueryMeta,
|
||||
params: CassandraParams,
|
||||
db: PostgresQueryable,
|
||||
): Promise<Array<StoredRow>> {
|
||||
const rowKeys = fullRowKeysFromWhere(meta, params);
|
||||
if (rowKeys) {
|
||||
const result = await db.query<StoredRow>(
|
||||
`SELECT row_key, row_data FROM ${this.table} WHERE table_name = $1 AND row_key = ANY($2::text[]) AND (expires_at IS NULL OR expires_at > now())`,
|
||||
[meta.table.name, rowKeys],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
if (hasFullPartition(meta)) {
|
||||
const result = await db.query<StoredRow>(
|
||||
`SELECT row_key, row_data FROM ${this.table} WHERE table_name = $1 AND partition_key = $2 AND (expires_at IS NULL OR expires_at > now())`,
|
||||
[meta.table.name, partitionKeyFromParams(meta, params)],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
private async candidates(meta: KvQueryMeta, plan: QueryPlan, db: PostgresQueryable): Promise<Array<StoredRow>> {
|
||||
if (plan.candidates.kind === 'none') return [];
|
||||
if (plan.candidates.kind === 'scan') logFullScan(meta);
|
||||
const fragments = planFragments(plan.candidates);
|
||||
const result = await db.query<StoredRow>(
|
||||
`SELECT row_key, row_data FROM ${this.table} WHERE table_name = $1 AND (expires_at IS NULL OR expires_at > now())`,
|
||||
[meta.table.name],
|
||||
`SELECT kv.row_key, kv.row_data FROM ${this.table} kv WHERE kv.table_name = $1${fragments.predicate} AND (kv.expires_at IS NULL OR kv.expires_at > now())`,
|
||||
[meta.table.name, ...fragments.params],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
private async select(meta: KvQueryMeta, params: CassandraParams, db: PostgresQueryable): Promise<Array<Row>> {
|
||||
let rows = (await this.candidates(meta, params, db))
|
||||
.map((stored) => decodeRow(stored.row_data))
|
||||
private matchingRows(meta: KvQueryMeta, stored: ReadonlyArray<StoredRow>, params: CassandraParams): Array<Row> {
|
||||
const required = queryShape(meta).requiredColumns;
|
||||
return stored
|
||||
.map((entry) => (required ? decodeRowColumns(entry.row_data, required) : decodeRow(entry.row_data)))
|
||||
.filter((row) => matchesWhere(row, meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, params));
|
||||
}
|
||||
|
||||
private async select(
|
||||
meta: KvQueryMeta,
|
||||
params: CassandraParams,
|
||||
plan: QueryPlan,
|
||||
db: PostgresQueryable,
|
||||
): Promise<Array<Row>> {
|
||||
let rows = this.matchingRows(meta, await this.candidates(meta, plan, db), params);
|
||||
rows = sortRows(meta, rows);
|
||||
if (typeof meta.limit === 'number') rows = rows.slice(0, meta.limit);
|
||||
return rows.map((row) => projectRow(row, meta.columns as ReadonlyArray<string> | undefined));
|
||||
}
|
||||
|
||||
private async count(meta: KvQueryMeta, params: CassandraParams, db: PostgresQueryable): Promise<Array<Row>> {
|
||||
const plan = buildCandidatePlan(meta, params);
|
||||
if (!plan.exact) {
|
||||
return [{count: (await this.select(meta, params, plan, db)).length}];
|
||||
}
|
||||
if (plan.candidates.kind === 'none') return [{count: 0}];
|
||||
if (plan.candidates.kind === 'scan') logFullScan(meta);
|
||||
const fragments = planFragments(plan.candidates);
|
||||
const result = await db.query<{count: string}>(
|
||||
`SELECT count(*) AS count FROM ${this.table} kv WHERE kv.table_name = $1${fragments.predicate} AND (kv.expires_at IS NULL OR kv.expires_at > now())`,
|
||||
[meta.table.name, ...fragments.params],
|
||||
);
|
||||
return [{count: Number(result.rows[0]?.count ?? 0)}];
|
||||
}
|
||||
|
||||
private async upsert(meta: KvQueryMeta, params: CassandraParams, db: PostgresQueryable): Promise<Array<Row>> {
|
||||
const incoming = rowFromParams(meta, params);
|
||||
const key = rowKey(meta, incoming);
|
||||
@@ -587,10 +903,26 @@ DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = EXCLUDED.row_da
|
||||
}
|
||||
|
||||
private async delete(meta: KvQueryMeta, params: CassandraParams, db: PostgresQueryable): Promise<void> {
|
||||
const rows = await this.candidates(meta, params, db);
|
||||
const plan = buildCandidatePlan(meta, params);
|
||||
if (plan.candidates.kind === 'none') return;
|
||||
if (plan.exact) {
|
||||
if (plan.candidates.kind === 'scan') logFullScan(meta);
|
||||
const fragments = planFragments(plan.candidates);
|
||||
await db.query(
|
||||
`DELETE FROM ${this.table} kv WHERE kv.table_name = $1${fragments.predicate} AND (kv.expires_at IS NULL OR kv.expires_at > now())`,
|
||||
[meta.table.name, ...fragments.params],
|
||||
);
|
||||
return;
|
||||
}
|
||||
const whereColumns = queryShape(meta).whereColumns;
|
||||
const rows = await this.candidates(meta, plan, db);
|
||||
const matchingKeys = rows
|
||||
.filter((stored) =>
|
||||
matchesWhere(decodeRow(stored.row_data), meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, params),
|
||||
matchesWhere(
|
||||
decodeRowColumns(stored.row_data, whereColumns),
|
||||
meta.where as ReadonlyArray<WhereExpr<Row>> | undefined,
|
||||
params,
|
||||
),
|
||||
)
|
||||
.map((stored) => stored.row_key);
|
||||
if (matchingKeys.length === 0) return;
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import cassandra from 'cassandra-driver';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {defineTable} from './CassandraTableDsl';
|
||||
import type {CassandraParams, PreparedQuery} from './CassandraTypes';
|
||||
import {buildCandidatePlan, type CandidatePlan, keyFromColumns, matchesWhere} from './PostgresKvQueryExecutor';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const SEPARATOR = '\u001f';
|
||||
|
||||
const TRICKY_STRINGS = [
|
||||
'',
|
||||
'a',
|
||||
'a"b',
|
||||
'a\\b',
|
||||
'a\u0001b',
|
||||
'a\u001fb',
|
||||
'a b',
|
||||
'a\u007fb',
|
||||
'\u{1f600}',
|
||||
'\ufffd',
|
||||
'a\u0000b',
|
||||
'b',
|
||||
];
|
||||
|
||||
function byteCompare(left: string, right: string): number {
|
||||
return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
|
||||
}
|
||||
|
||||
function withinRange(rowKey: string, lowerBound: string, upperBound: string): boolean {
|
||||
return byteCompare(rowKey, lowerBound) >= 0 && byteCompare(rowKey, upperBound) < 0;
|
||||
}
|
||||
|
||||
function planAccepts(plan: CandidatePlan, rowKey: string, partitionKey: string): boolean {
|
||||
switch (plan.kind) {
|
||||
case 'none':
|
||||
return false;
|
||||
case 'rowKeys':
|
||||
return plan.rowKeys.includes(rowKey);
|
||||
case 'range':
|
||||
return withinRange(rowKey, plan.lowerBound, plan.upperBound);
|
||||
case 'ranges':
|
||||
return plan.lowerBounds.some((lower, index) => withinRange(rowKey, lower, plan.upperBounds[index]!));
|
||||
case 'partitionKeys':
|
||||
return plan.partitionKeys.includes(partitionKey);
|
||||
case 'scan':
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function matchingRangeCount(plan: CandidatePlan, rowKey: string): number {
|
||||
if (plan.kind !== 'ranges') return 0;
|
||||
return plan.lowerBounds.filter((lower, index) => withinRange(rowKey, lower, plan.upperBounds[index]!)).length;
|
||||
}
|
||||
|
||||
function expectKeyShape(key: string, separators: number): void {
|
||||
let seen = 0;
|
||||
for (const byte of Buffer.from(key, 'utf8')) {
|
||||
if (byte === 0x1f) {
|
||||
seen += 1;
|
||||
continue;
|
||||
}
|
||||
expect(byte).toBeGreaterThanOrEqual(0x20);
|
||||
}
|
||||
expect(seen).toBe(separators);
|
||||
}
|
||||
|
||||
function planOf(query: PreparedQuery) {
|
||||
const meta = query.kvMeta;
|
||||
if (!meta) {
|
||||
throw new Error('Query is missing kv metadata');
|
||||
}
|
||||
return {meta, plan: buildCandidatePlan(meta, query.params)};
|
||||
}
|
||||
|
||||
function checkQuery(query: PreparedQuery, rows: Array<Row>): CandidatePlan {
|
||||
const {meta, plan} = planOf(query);
|
||||
const primaryKey = meta.table.primaryKey as ReadonlyArray<string>;
|
||||
const partitionKey = meta.table.partitionKey as ReadonlyArray<string>;
|
||||
const accepted: Array<Row> = [];
|
||||
const matched: Array<Row> = [];
|
||||
for (const row of rows) {
|
||||
const rowKey = keyFromColumns(primaryKey, row);
|
||||
expectKeyShape(rowKey, primaryKey.length - 1);
|
||||
expect(matchingRangeCount(plan.candidates, rowKey)).toBeLessThanOrEqual(1);
|
||||
if (planAccepts(plan.candidates, rowKey, keyFromColumns(partitionKey, row))) accepted.push(row);
|
||||
if (matchesWhere(row, meta.where, query.params)) matched.push(row);
|
||||
}
|
||||
for (const row of matched) {
|
||||
expect(accepted).toContain(row);
|
||||
}
|
||||
if (plan.exact) {
|
||||
expect(accepted).toEqual(matched);
|
||||
}
|
||||
return plan.candidates;
|
||||
}
|
||||
|
||||
interface SingleRow {
|
||||
a: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
const SingleKey = defineTable<SingleRow, 'a'>({
|
||||
name: 'kv_plan_single',
|
||||
columns: ['a', 'payload'],
|
||||
primaryKey: ['a'],
|
||||
});
|
||||
|
||||
const singleRows: Array<Row> = TRICKY_STRINGS.map((a) => ({a, payload: 'p'}));
|
||||
|
||||
interface PairRow {
|
||||
a: string;
|
||||
b: bigint;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
const PairKey = defineTable<PairRow, 'a' | 'b'>({
|
||||
name: 'kv_plan_pair',
|
||||
columns: ['a', 'b', 'payload'],
|
||||
primaryKey: ['a', 'b'],
|
||||
});
|
||||
|
||||
const pairRows: Array<Row> = TRICKY_STRINGS.flatMap((a) =>
|
||||
[-1n, 0n, 9n, 10n, 1000n].map((b) => ({a, b, payload: 'p'})),
|
||||
);
|
||||
|
||||
interface TripleRow {
|
||||
a: string;
|
||||
b: bigint;
|
||||
c: boolean;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
const TripleKey = defineTable<TripleRow, 'a' | 'b' | 'c'>({
|
||||
name: 'kv_plan_triple',
|
||||
columns: ['a', 'b', 'c', 'payload'],
|
||||
primaryKey: ['a', 'b', 'c'],
|
||||
});
|
||||
|
||||
const tripleRows: Array<Row> = TRICKY_STRINGS.flatMap((a) =>
|
||||
[9n, 10n].flatMap((b) => [true, false].map((c) => ({a, b, c, payload: 'p'}))),
|
||||
);
|
||||
|
||||
interface WideRow {
|
||||
a: string;
|
||||
b: bigint;
|
||||
c: boolean;
|
||||
d: Date | null;
|
||||
e: Buffer;
|
||||
f: number;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
const WideKey = defineTable<WideRow, 'a' | 'b' | 'c' | 'd' | 'e' | 'f'>({
|
||||
name: 'kv_plan_wide',
|
||||
columns: ['a', 'b', 'c', 'd', 'e', 'f', 'payload'],
|
||||
primaryKey: ['a', 'b', 'c', 'd', 'e', 'f'],
|
||||
});
|
||||
|
||||
const wideRows: Array<Row> = ['a', 'a"b', '\u{1f600}'].flatMap((a) =>
|
||||
[9n, 10n].flatMap((b) =>
|
||||
[true, false].flatMap((c) =>
|
||||
[null, new Date('2020-01-01T00:00:00.000Z')].flatMap((d) =>
|
||||
[Buffer.from('one'), Buffer.from([0x00, 0x1f, 0xff])].flatMap((e) =>
|
||||
[1, 2].map((f) => ({a, b, c, d, e, f, payload: 'p'})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
interface PartitionedRow {
|
||||
a: string;
|
||||
b: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
const PartitionedKey = defineTable<PartitionedRow, 'a' | 'b', 'b'>({
|
||||
name: 'kv_plan_partitioned',
|
||||
columns: ['a', 'b', 'payload'],
|
||||
primaryKey: ['a', 'b'],
|
||||
partitionKey: ['b'],
|
||||
});
|
||||
|
||||
const partitionedRows: Array<Row> = TRICKY_STRINGS.flatMap((a) => TRICKY_STRINGS.map((b) => ({a, b, payload: 'p'})));
|
||||
|
||||
describe('PostgresKvQueryExecutor candidate planner', () => {
|
||||
it('pins a single-column primary key by equality', () => {
|
||||
for (const a of TRICKY_STRINGS) {
|
||||
expect(checkQuery(SingleKey.select({where: SingleKey.where.eq('a')}).bind({a}), singleRows).kind).toBe('rowKeys');
|
||||
}
|
||||
});
|
||||
|
||||
it('pins a single-column primary key by IN', () => {
|
||||
const query = SingleKey.select({where: SingleKey.where.in('a', 'ids')}).bind({
|
||||
ids: ['a', 'a\u0001b', 'a"b', 'a'],
|
||||
});
|
||||
expect(checkQuery(query, singleRows).kind).toBe('rowKeys');
|
||||
});
|
||||
|
||||
it('returns nothing for an empty IN list', () => {
|
||||
for (const params of [{ids: []}, {ids: new Set<string>()}, {}]) {
|
||||
const query = SingleKey.select({where: SingleKey.where.in('a', 'ids')}).bind(params as CassandraParams);
|
||||
expect(checkQuery(query, singleRows).kind).toBe('none');
|
||||
}
|
||||
});
|
||||
|
||||
it('ranges over the leading column of a composite primary key', () => {
|
||||
for (const a of TRICKY_STRINGS) {
|
||||
expect(checkQuery(PairKey.select({where: PairKey.where.eq('a')}).bind({a}), pairRows).kind).toBe('range');
|
||||
}
|
||||
});
|
||||
|
||||
it('ranges over the leading column when a comparison follows', () => {
|
||||
const query = PairKey.select({where: [PairKey.where.eq('a'), PairKey.where.lt('b')]}).bind({
|
||||
a: 'a\u0001b',
|
||||
b: 10n,
|
||||
});
|
||||
expect(checkQuery(query, pairRows).kind).toBe('range');
|
||||
});
|
||||
|
||||
it('emits one range per value when the leading column uses IN', () => {
|
||||
const query = PairKey.select({where: PairKey.where.in('a', 'ids')}).bind({
|
||||
ids: ['a', 'a\u0001b', 'a\u001fb', 'a'],
|
||||
});
|
||||
expect(checkQuery(query, pairRows).kind).toBe('ranges');
|
||||
});
|
||||
|
||||
it('pins the full primary key across a cartesian product of IN lists', () => {
|
||||
const query = PairKey.select({where: [PairKey.where.in('a', 'ids'), PairKey.where.in('b', 'bs')]}).bind({
|
||||
ids: ['a', 'a\u007fb'],
|
||||
bs: [9n, 10n],
|
||||
});
|
||||
expect(checkQuery(query, pairRows).kind).toBe('rowKeys');
|
||||
});
|
||||
|
||||
it('ranges over a two-column prefix of a three-column primary key', () => {
|
||||
for (const a of TRICKY_STRINGS) {
|
||||
const query = TripleKey.select({where: [TripleKey.where.eq('a'), TripleKey.where.eq('b')]}).bind({a, b: 9n});
|
||||
expect(checkQuery(query, tripleRows).kind).toBe('range');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a scan when the leading column is unpinned', () => {
|
||||
const query = TripleKey.select({where: [TripleKey.where.eq('b'), TripleKey.where.eq('c')]}).bind({b: 9n, c: true});
|
||||
expect(checkQuery(query, tripleRows).kind).toBe('scan');
|
||||
});
|
||||
|
||||
it('pins every column of a six-column primary key', () => {
|
||||
const query = WideKey.select({
|
||||
where: [
|
||||
WideKey.where.eq('a'),
|
||||
WideKey.where.eq('b'),
|
||||
WideKey.where.eq('c'),
|
||||
WideKey.where.eq('d'),
|
||||
WideKey.where.eq('e'),
|
||||
WideKey.where.eq('f'),
|
||||
],
|
||||
}).bind({a: '\u{1f600}', b: 10n, c: false, d: null, e: Buffer.from([0x00, 0x1f, 0xff]), f: 2});
|
||||
expect(checkQuery(query, wideRows).kind).toBe('rowKeys');
|
||||
});
|
||||
|
||||
it('stops at the first gap in the primary key', () => {
|
||||
const query = WideKey.select({
|
||||
where: [WideKey.where.eq('a'), WideKey.where.eq('b'), WideKey.where.eq('f')],
|
||||
}).bind({a: 'a', b: 9n, f: 1});
|
||||
expect(checkQuery(query, wideRows).kind).toBe('range');
|
||||
});
|
||||
|
||||
it('uses the partition key when it is not a prefix of the primary key', () => {
|
||||
for (const b of TRICKY_STRINGS) {
|
||||
const query = PartitionedKey.select({where: PartitionedKey.where.eq('b')}).bind({b});
|
||||
expect(checkQuery(query, partitionedRows).kind).toBe('partitionKeys');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the partition key for an IN on the partition column', () => {
|
||||
const query = PartitionedKey.select({where: PartitionedKey.where.in('b', 'bs')}).bind({
|
||||
bs: new Set(['a', 'a\u0001b', 'a\\b']),
|
||||
});
|
||||
expect(checkQuery(query, partitionedRows).kind).toBe('partitionKeys');
|
||||
});
|
||||
|
||||
it('scans a table with no predicate', () => {
|
||||
expect(checkQuery(SingleKey.select().bind({}), singleRows).kind).toBe('scan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PostgresKvQueryExecutor plan exactness', () => {
|
||||
it('is exact when the key predicate consumes every clause', () => {
|
||||
expect(planOf(PairKey.select({where: PairKey.where.eq('a')}).bind({a: 'a'})).plan.exact).toBe(true);
|
||||
expect(planOf(SingleKey.select().bind({})).plan.exact).toBe(true);
|
||||
expect(planOf(PartitionedKey.select({where: PartitionedKey.where.eq('b')}).bind({b: 'a'})).plan.exact).toBe(true);
|
||||
});
|
||||
|
||||
it('is inexact when a clause is left for the JavaScript filter', () => {
|
||||
const bounded = PairKey.select({where: [PairKey.where.eq('a'), PairKey.where.lt('b')]}).bind({a: 'a', b: 9n});
|
||||
expect(planOf(bounded).plan.exact).toBe(false);
|
||||
expect(planOf(TripleKey.select({where: TripleKey.where.eq('b')}).bind({b: 9n})).plan.exact).toBe(false);
|
||||
});
|
||||
|
||||
it('is inexact when a limit or an order is present', () => {
|
||||
expect(planOf(PairKey.select({where: PairKey.where.eq('a'), limit: 5}).bind({a: 'a'})).plan.exact).toBe(false);
|
||||
const ordered = PairKey.select({where: PairKey.where.eq('a'), orderBy: {col: 'b'}}).bind({a: 'a'});
|
||||
expect(planOf(ordered).plan.exact).toBe(false);
|
||||
});
|
||||
|
||||
it('is inexact when a pinned value has no stable key encoding', () => {
|
||||
expect(planOf(SingleKey.select({where: SingleKey.where.eq('a')}).bind({a: {}})).plan.exact).toBe(false);
|
||||
expect(planOf(SingleKey.select({where: SingleKey.where.eq('a')}).bind({a: Number.NaN})).plan.exact).toBe(false);
|
||||
});
|
||||
|
||||
it('is exact for an empty IN list regardless of the other clauses', () => {
|
||||
const query = PairKey.select({where: [PairKey.where.in('a', 'ids'), PairKey.where.lt('b')], limit: 3}).bind({
|
||||
ids: [],
|
||||
b: 9n,
|
||||
});
|
||||
expect(planOf(query).plan.exact).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PostgresKvQueryExecutor plan totality', () => {
|
||||
it('leaves a column unpinned when its value has no key encoding at all', () => {
|
||||
const invalid = PairKey.select({where: PairKey.where.eq('a')}).bind({a: new Date('nope')});
|
||||
const {plan} = planOf(invalid);
|
||||
expect(plan.candidates.kind).toBe('scan');
|
||||
expect(plan.exact).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a trailing column unpinned without losing the leading prefix', () => {
|
||||
const query = PairKey.select({where: [PairKey.where.eq('a'), PairKey.where.eq('b')]}).bind({
|
||||
a: 'a',
|
||||
b: new Date('nope') as never,
|
||||
});
|
||||
const {plan} = planOf(query);
|
||||
expect(plan.candidates.kind).toBe('range');
|
||||
expect(plan.exact).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat a scalar bound to an IN parameter as an iterable', () => {
|
||||
const query = SingleKey.select({where: SingleKey.where.in('a', 'ids')}).bind({ids: 'ab' as never});
|
||||
const {plan} = planOf(query);
|
||||
expect(plan.candidates.kind).toBe('scan');
|
||||
expect(plan.exact).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PostgresKvQueryExecutor plan size caps', () => {
|
||||
it('keeps a full primary key product as row keys rather than demoting it to ranges', () => {
|
||||
const ids = Array.from({length: 30}, (_, i) => `id${i}`);
|
||||
const seqs = Array.from({length: 30}, (_, i) => BigInt(i));
|
||||
const query = PairKey.select({where: [PairKey.where.in('a', 'ids'), PairKey.where.in('b', 'seqs')]}).bind({
|
||||
ids,
|
||||
seqs,
|
||||
});
|
||||
const {plan} = planOf(query);
|
||||
expect(plan.candidates.kind).toBe('rowKeys');
|
||||
expect(plan.exact).toBe(true);
|
||||
if (plan.candidates.kind !== 'rowKeys') return;
|
||||
expect(plan.candidates.rowKeys.length).toBe(900);
|
||||
});
|
||||
|
||||
it('caps the number of prefix ranges and degrades to a scan instead', () => {
|
||||
const under = TripleKey.select({where: TripleKey.where.in('a', 'ids')}).bind({
|
||||
ids: Array.from({length: 256}, (_, i) => `id${i}`),
|
||||
});
|
||||
expect(planOf(under).plan.candidates.kind).toBe('ranges');
|
||||
const over = TripleKey.select({where: TripleKey.where.in('a', 'ids')}).bind({
|
||||
ids: Array.from({length: 257}, (_, i) => `id${i}`),
|
||||
});
|
||||
const {plan} = planOf(over);
|
||||
expect(plan.candidates.kind).toBe('scan');
|
||||
expect(plan.exact).toBe(false);
|
||||
});
|
||||
|
||||
it('enforces the cap even when the trailing pinned column is an equality', () => {
|
||||
const ids = Array.from({length: 40}, (_, i) => `id${i}`);
|
||||
const query = WideKey.select({
|
||||
where: [WideKey.where.in('a', 'ids'), WideKey.where.in('b', 'seqs'), WideKey.where.eq('c')],
|
||||
}).bind({ids, seqs: Array.from({length: 40}, (_, i) => BigInt(i)), c: true});
|
||||
const {plan} = planOf(query);
|
||||
expect(plan.candidates.kind).toBe('ranges');
|
||||
expect(plan.exact).toBe(false);
|
||||
if (plan.candidates.kind !== 'ranges') return;
|
||||
expect(plan.candidates.lowerBounds.length).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PostgresKvQueryExecutor prefix bounds', () => {
|
||||
it('brackets exactly the rows sharing the pinned prefix', () => {
|
||||
const {plan} = planOf(PairKey.select({where: PairKey.where.eq('a')}).bind({a: 'a'}));
|
||||
expect(plan.candidates.kind).toBe('range');
|
||||
if (plan.candidates.kind !== 'range') return;
|
||||
expect(plan.candidates.lowerBound).toBe(`"a"${SEPARATOR}`);
|
||||
expect(plan.candidates.upperBound).toBe('"a" ');
|
||||
expect(byteCompare(plan.candidates.lowerBound, plan.candidates.upperBound)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PostgresKvQueryExecutor key equality', () => {
|
||||
const eqWhere = [{kind: 'eq', col: 'a', param: 'a'}] as never;
|
||||
const corpus: Array<[string, unknown]> = [
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['empty-string', ''],
|
||||
['date-string', '2020-01-01'],
|
||||
['days-string', '18262'],
|
||||
['zero', 0],
|
||||
['neg-zero', -0],
|
||||
['number', 18262],
|
||||
['bigint', 18262n],
|
||||
['true', true],
|
||||
['false', false],
|
||||
['date', new Date('2020-01-01T00:00:00.000Z')],
|
||||
['buffer', Buffer.from('2020-01-01')],
|
||||
['local-date', cassandra.types.LocalDate.fromString('2020-01-01')],
|
||||
['other-local-date', cassandra.types.LocalDate.fromString('2021-06-05')],
|
||||
];
|
||||
|
||||
it('accepts an equality exactly when both sides share a row key', () => {
|
||||
const deltas: Array<string> = [];
|
||||
for (const [storedName, stored] of corpus) {
|
||||
for (const [paramName, param] of corpus) {
|
||||
const accepted = matchesWhere({a: stored}, eqWhere, {a: param} as CassandraParams);
|
||||
const sameKey = keyFromColumns(['a'], {a: stored}) === keyFromColumns(['a'], {a: param});
|
||||
if (accepted !== sameKey)
|
||||
deltas.push(`stored=${storedName} param=${paramName} match=${accepted} key=${sameKey}`);
|
||||
}
|
||||
}
|
||||
expect(deltas).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not equate a LocalDate with its string rendering', () => {
|
||||
const localDate = cassandra.types.LocalDate.fromString('2020-01-01');
|
||||
expect(matchesWhere({a: localDate}, eqWhere, {a: localDate} as CassandraParams)).toBe(true);
|
||||
expect(matchesWhere({a: localDate}, eqWhere, {a: '2020-01-01'} as CassandraParams)).toBe(false);
|
||||
expect(matchesWhere({a: '2020-01-01'}, eqWhere, {a: localDate} as CassandraParams)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {execFileSync, spawnSync} from 'node:child_process';
|
||||
import {createServer} from 'node:net';
|
||||
import {
|
||||
getDefaultPostgresClient,
|
||||
type IPostgresClient,
|
||||
initPostgres,
|
||||
type PostgresQueryable,
|
||||
shutdownPostgres,
|
||||
} from '@pkgs/postgres/src/Client';
|
||||
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||
import {GuildMembers, ReadStates, Users} from '../Tables';
|
||||
import {LegacyPostgresKvQueryExecutor, legacyEnsurePostgresKvSchema} from './__testref__/LegacyPostgresKvQueryExecutor';
|
||||
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from './PostgresKvQueryExecutor';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const POSTGRES_IMAGE = 'postgres:16-alpine';
|
||||
const CONTAINER = `fluxer-kvupgrade-${process.pid.toString(36)}-${Date.now().toString(36)}`;
|
||||
const KV = 'kv_upgrade';
|
||||
const dockerUp = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
|
||||
|
||||
let PORT = 0;
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (typeof address === 'string' || address === null) {
|
||||
reject(new Error('no port'));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (dockerUp) {
|
||||
beforeAll(async () => {
|
||||
PORT = await freePort();
|
||||
execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
CONTAINER,
|
||||
'-e',
|
||||
'POSTGRES_USER=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_PASSWORD=fluxer',
|
||||
'-e',
|
||||
'POSTGRES_DB=fluxer',
|
||||
'-p',
|
||||
`127.0.0.1:${PORT}:5432`,
|
||||
POSTGRES_IMAGE,
|
||||
'-c',
|
||||
'fsync=off',
|
||||
'-c',
|
||||
'synchronous_commit=off',
|
||||
],
|
||||
{stdio: 'ignore'},
|
||||
);
|
||||
for (let attempt = 0; attempt < 180; attempt += 1) {
|
||||
await sleep(500);
|
||||
const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
if (probe.status !== 0) continue;
|
||||
try {
|
||||
await initPostgres({url: `postgres://fluxer:[email protected]:${PORT}/fluxer`, maxConnections: 8});
|
||||
await getDefaultPostgresClient().query('SELECT 1');
|
||||
return;
|
||||
} catch {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
}
|
||||
}
|
||||
throw new Error('postgres not ready');
|
||||
}, 900_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
}
|
||||
|
||||
class TableClient implements IPostgresClient {
|
||||
constructor(
|
||||
private readonly inner: IPostgresClient,
|
||||
private readonly name: string,
|
||||
) {}
|
||||
query: IPostgresClient['query'] = (text, values) => this.inner.query(text, values);
|
||||
async connect() {
|
||||
await this.inner.connect();
|
||||
}
|
||||
async shutdown() {}
|
||||
isConnected() {
|
||||
return this.inner.isConnected();
|
||||
}
|
||||
async transaction<T>(fn: (c: PostgresQueryable) => Promise<T>) {
|
||||
return this.inner.transaction(fn);
|
||||
}
|
||||
kvTable() {
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
|
||||
function fp(rows: ReadonlyArray<Row>): string {
|
||||
return rows
|
||||
.map((r) =>
|
||||
Object.entries(r)
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join('|'),
|
||||
)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
function pq(cql: string, params: Record<string, unknown>) {
|
||||
return {cql, params} as never;
|
||||
}
|
||||
|
||||
const suite = dockerUp ? describe : describe.skip;
|
||||
|
||||
suite('postgres kv upgrade safety', () => {
|
||||
let raw: IPostgresClient;
|
||||
let client: TableClient;
|
||||
let legacy: LegacyPostgresKvQueryExecutor;
|
||||
let next: PostgresKvQueryExecutor;
|
||||
|
||||
const usersScan = () => pq(Users.selectCql(), {});
|
||||
|
||||
beforeAll(async () => {
|
||||
await initPostgres({url: `postgres://fluxer:[email protected]:${PORT}/fluxer`, maxConnections: 8});
|
||||
raw = getDefaultPostgresClient();
|
||||
await raw.query(`DROP TABLE IF EXISTS ${KV}`);
|
||||
client = new TableClient(raw, KV);
|
||||
await legacyEnsurePostgresKvSchema(client);
|
||||
legacy = new LegacyPostgresKvQueryExecutor(client);
|
||||
next = new PostgresKvQueryExecutor(client);
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
await legacy.executeQuery(
|
||||
Users.upsertAll({user_id: BigInt(1000 + i), username: `u${i}`, discriminator: `${i}`} as never) as never,
|
||||
);
|
||||
}
|
||||
for (let g = 0; g < 3; g += 1) {
|
||||
for (let u = 0; u < 12; u += 1) {
|
||||
await legacy.executeQuery(
|
||||
GuildMembers.upsertAll({guild_id: BigInt(500 + g), user_id: BigInt(1000 + u)} as never) as never,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (let u = 0; u < 5; u += 1) {
|
||||
for (let c = 0; c < 4; c += 1) {
|
||||
await legacy.executeQuery(
|
||||
ReadStates.upsertAll({user_id: BigInt(1000 + u), channel_id: BigInt(7000 + c)} as never) as never,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownPostgres().catch(() => {});
|
||||
});
|
||||
|
||||
it('reads data written by the old image identically', async () => {
|
||||
const shapes = [
|
||||
{name: 'users scan', q: usersScan()},
|
||||
{
|
||||
name: 'guild members by guild',
|
||||
q: pq(GuildMembers.selectCql({where: GuildMembers.where.eq('guild_id')}), {guild_id: 501n}),
|
||||
},
|
||||
{
|
||||
name: 'read states by user',
|
||||
q: pq(ReadStates.selectCql({where: ReadStates.where.eq('user_id')}), {user_id: 1002n}),
|
||||
},
|
||||
{name: 'user by id', q: pq(Users.selectCql({where: Users.where.eq('user_id')}), {user_id: 1005n})},
|
||||
];
|
||||
for (const shape of shapes) {
|
||||
const a = await legacy.executeQuery(shape.q);
|
||||
const b = await next.executeQuery(shape.q);
|
||||
expect(fp(b), shape.name).toBe(fp(a));
|
||||
expect(a.length, shape.name).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
async function drain(order: Array<'legacy' | 'next'>, pageSize: number) {
|
||||
const keys: Array<string> = [];
|
||||
let state: string | null = null;
|
||||
let pages = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const who = order[pages % order.length]!;
|
||||
const exec = who === 'legacy' ? legacy : next;
|
||||
const page: {rows: Array<Row>; pageState: string | null} = await exec.executePagedQuery(usersScan(), {
|
||||
pageSize,
|
||||
pageState: state,
|
||||
});
|
||||
pages += 1;
|
||||
for (const row of page.rows) keys.push(String(row.user_id));
|
||||
state = page.pageState;
|
||||
if (!state) break;
|
||||
if (pages > 200) throw new Error('runaway paging loop');
|
||||
}
|
||||
} catch (error) {
|
||||
return {keys, error: (error as Error).message, pages};
|
||||
}
|
||||
return {keys, error: null, pages};
|
||||
}
|
||||
|
||||
it('pages consistently with only the old image running', async () => {
|
||||
const r = await drain(['legacy'], 7);
|
||||
expect(r.error).toBeNull();
|
||||
expect(r.keys.length).toBe(40);
|
||||
expect(new Set(r.keys).size).toBe(40);
|
||||
});
|
||||
|
||||
it('pages consistently with only the new image running', async () => {
|
||||
const r = await drain(['next'], 7);
|
||||
expect(r.error).toBeNull();
|
||||
expect(r.keys.length).toBe(40);
|
||||
expect(new Set(r.keys).size).toBe(40);
|
||||
});
|
||||
|
||||
it('ROLLING RESTART: an old replica consumes a token minted by the new one', async () => {
|
||||
const first = await next.executePagedQuery(usersScan(), {pageSize: 7, pageState: null});
|
||||
expect(first.pageState).toBeTruthy();
|
||||
const token = JSON.parse(Buffer.from(first.pageState!, 'base64url').toString('utf8'));
|
||||
let failure: string | null = null;
|
||||
let rows = 0;
|
||||
try {
|
||||
const second = await legacy.executePagedQuery(usersScan(), {pageSize: 7, pageState: first.pageState});
|
||||
rows = second.rows.length;
|
||||
} catch (error) {
|
||||
failure = (error as Error).message;
|
||||
}
|
||||
console.log(`NEW->OLD token=${JSON.stringify(token)} legacy=${failure ?? `${rows} rows`}`);
|
||||
expect(failure).toBeNull();
|
||||
});
|
||||
|
||||
it('ROLLING RESTART: alternating old and new replicas drain the scan', async () => {
|
||||
const a = await drain(['next', 'legacy'], 7);
|
||||
const b = await drain(['legacy', 'next'], 7);
|
||||
console.log(`new-first err=${a.error} pages=${a.pages} total=${a.keys.length} unique=${new Set(a.keys).size}`);
|
||||
console.log(`old-first err=${b.error} pages=${b.pages} total=${b.keys.length} unique=${new Set(b.keys).size}`);
|
||||
expect(a.error).toBeNull();
|
||||
expect(new Set(a.keys).size).toBe(40);
|
||||
expect(b.error).toBeNull();
|
||||
expect(new Set(b.keys).size).toBe(40);
|
||||
});
|
||||
|
||||
it('a dual {offset, after} token would survive both directions', async () => {
|
||||
const first = await next.executePagedQuery(usersScan(), {pageSize: 7, pageState: null});
|
||||
const cursor = JSON.parse(Buffer.from(first.pageState!, 'base64url').toString('utf8')) as {after: string};
|
||||
const dual = Buffer.from(JSON.stringify({offset: first.rows.length, after: cursor.after})).toString('base64url');
|
||||
const viaOld = await legacy.executePagedQuery(usersScan(), {pageSize: 7, pageState: dual});
|
||||
const viaNew = await next.executePagedQuery(usersScan(), {pageSize: 7, pageState: dual});
|
||||
console.log(`dual token -> old replica: ${viaOld.rows.length} rows, new replica: ${viaNew.rows.length} rows`);
|
||||
expect(viaOld.rows.length).toBe(7);
|
||||
expect(viaNew.rows.length).toBe(7);
|
||||
});
|
||||
|
||||
it('deletes exactly the same rows as the old image', async () => {
|
||||
const seed = async (exec: LegacyPostgresKvQueryExecutor | PostgresKvQueryExecutor, base: bigint) => {
|
||||
for (let c = 0; c < 5; c += 1) {
|
||||
await exec.executeQuery(ReadStates.upsertAll({user_id: base, channel_id: BigInt(9000 + c)} as never) as never);
|
||||
}
|
||||
};
|
||||
await seed(legacy, 4001n);
|
||||
await seed(legacy, 4002n);
|
||||
await legacy.executeQuery(
|
||||
ReadStates.delete({where: ReadStates.where.eq('user_id')}).bind({user_id: 4001n}) as never,
|
||||
);
|
||||
await next.executeQuery(ReadStates.delete({where: ReadStates.where.eq('user_id')}).bind({user_id: 4002n}) as never);
|
||||
const left = await raw.query<{row_key: string}>(
|
||||
`SELECT row_key FROM ${KV} WHERE table_name = 'read_states' AND row_key LIKE '%400%' ORDER BY row_key`,
|
||||
);
|
||||
console.log(`rows left after legacy delete + new delete: ${left.rows.length}`);
|
||||
expect(left.rows.length).toBe(0);
|
||||
});
|
||||
|
||||
it('writes byte-identical row_key and partition_key to the old image', async () => {
|
||||
const OLD = `${KV}_w_old`;
|
||||
const NEW = `${KV}_w_new`;
|
||||
await raw.query(`DROP TABLE IF EXISTS ${OLD}`);
|
||||
await raw.query(`DROP TABLE IF EXISTS ${NEW}`);
|
||||
const oldClient = new TableClient(raw, OLD);
|
||||
const newClient = new TableClient(raw, NEW);
|
||||
await legacyEnsurePostgresKvSchema(oldClient);
|
||||
await ensurePostgresKvSchema(newClient);
|
||||
const oldExec = new LegacyPostgresKvQueryExecutor(oldClient);
|
||||
const newExec = new PostgresKvQueryExecutor(newClient);
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
const row = {user_id: BigInt(9000 + i), channel_id: BigInt(500 + i)} as never;
|
||||
await oldExec.executeQuery(ReadStates.upsertAll(row) as never);
|
||||
await newExec.executeQuery(ReadStates.upsertAll(row) as never);
|
||||
const g = {guild_id: BigInt(70 + i), user_id: BigInt(9000 + i)} as never;
|
||||
await oldExec.executeQuery(GuildMembers.upsertAll(g) as never);
|
||||
await newExec.executeQuery(GuildMembers.upsertAll(g) as never);
|
||||
}
|
||||
const diff = await raw.query<{n: string}>(`
|
||||
SELECT count(*) AS n FROM (
|
||||
(SELECT table_name, partition_key, row_key FROM ${OLD}
|
||||
EXCEPT SELECT table_name, partition_key, row_key FROM ${NEW})
|
||||
UNION ALL
|
||||
(SELECT table_name, partition_key, row_key FROM ${NEW}
|
||||
EXCEPT SELECT table_name, partition_key, row_key FROM ${OLD})
|
||||
) d`);
|
||||
const schemaDiff = await raw.query<{n: string}>(`
|
||||
SELECT count(*) AS n FROM (
|
||||
(SELECT replace(indexdef, '${OLD}', 'KV') FROM pg_indexes WHERE tablename = '${OLD}'
|
||||
EXCEPT SELECT replace(indexdef, '${NEW}', 'KV') FROM pg_indexes WHERE tablename = '${NEW}')
|
||||
UNION ALL
|
||||
(SELECT replace(indexdef, '${NEW}', 'KV') FROM pg_indexes WHERE tablename = '${NEW}'
|
||||
EXCEPT SELECT replace(indexdef, '${OLD}', 'KV') FROM pg_indexes WHERE tablename = '${OLD}')
|
||||
) d`);
|
||||
console.log(`key diffs=${diff.rows[0]!.n} index-definition diffs=${schemaDiff.rows[0]!.n}`);
|
||||
expect(Number(diff.rows[0]!.n)).toBe(0);
|
||||
expect(Number(schemaDiff.rows[0]!.n)).toBe(0);
|
||||
});
|
||||
|
||||
it('survives three simultaneous boots (two api replicas and a worker)', async () => {
|
||||
const BOOT = `${KV}_boot`;
|
||||
await raw.query(`DROP TABLE IF EXISTS ${BOOT}`);
|
||||
const results = await Promise.allSettled([
|
||||
ensurePostgresKvSchema(new TableClient(raw, BOOT)),
|
||||
ensurePostgresKvSchema(new TableClient(raw, BOOT)),
|
||||
ensurePostgresKvSchema(new TableClient(raw, BOOT)),
|
||||
]);
|
||||
const failures = results.filter((r) => r.status === 'rejected');
|
||||
console.log(`concurrent boots rejected: ${failures.length}`);
|
||||
for (const f of failures) console.log(` ${(f as PromiseRejectedResult).reason}`);
|
||||
expect(failures.length).toBe(0);
|
||||
}, 120_000);
|
||||
|
||||
it('backfills the messages partition key once and then skips the update', async () => {
|
||||
const BACKFILL = 'kv_backfill';
|
||||
const legacyKey = `"c"${String.fromCharCode(31)}"b"${String.fromCharCode(31)}"m"`;
|
||||
const backfillClient = new TableClient(raw, BACKFILL);
|
||||
await raw.query(`DROP TABLE IF EXISTS ${BACKFILL}`);
|
||||
await ensurePostgresKvSchema(backfillClient);
|
||||
await raw.query(
|
||||
`INSERT INTO ${BACKFILL} (table_name, partition_key, row_key, row_data) VALUES ('messages', $1, $1, '{}'::jsonb)`,
|
||||
[legacyKey],
|
||||
);
|
||||
const pendingBefore = await raw.query(
|
||||
`SELECT 1 FROM ${BACKFILL} WHERE table_name = 'messages' AND partition_key = row_key AND split_part(row_key, chr(31), 3) <> ''`,
|
||||
);
|
||||
expect(pendingBefore.rows.length).toBe(1);
|
||||
await ensurePostgresKvSchema(backfillClient);
|
||||
const after = await raw.query<{partition_key: string}>(`SELECT partition_key FROM ${BACKFILL}`);
|
||||
expect(after.rows[0]?.partition_key).toBe(`"c"${String.fromCharCode(31)}"b"`);
|
||||
const pendingAfter = await raw.query(
|
||||
`SELECT 1 FROM ${BACKFILL} WHERE table_name = 'messages' AND partition_key = row_key AND split_part(row_key, chr(31), 3) <> ''`,
|
||||
);
|
||||
expect(pendingAfter.rows.length).toBe(0);
|
||||
await ensurePostgresKvSchema(backfillClient);
|
||||
const stable = await raw.query<{partition_key: string}>(`SELECT partition_key FROM ${BACKFILL}`);
|
||||
expect(stable.rows[0]?.partition_key).toBe(`"c"${String.fromCharCode(31)}"b"`);
|
||||
await raw.query(`DROP TABLE IF EXISTS ${BACKFILL}`);
|
||||
}, 120_000);
|
||||
|
||||
it('boot does not repair an invalid C-collation index', async () => {
|
||||
const q = pq(ReadStates.selectCql({where: ReadStates.where.eq('user_id')}), {user_id: 1002n});
|
||||
const before = await next.executeQuery(q);
|
||||
await raw.query(`UPDATE pg_index SET indisvalid = false WHERE indexrelid = '${KV}_row_key_c_idx'::regclass`);
|
||||
await ensurePostgresKvSchema(client);
|
||||
const check = await raw.query<{indisvalid: boolean}>(
|
||||
`SELECT indisvalid FROM pg_index WHERE indexrelid = '${KV}_row_key_c_idx'::regclass`,
|
||||
);
|
||||
const after = await next.executeQuery(q);
|
||||
await raw.query(`UPDATE pg_index SET indisvalid = true WHERE indexrelid = '${KV}_row_key_c_idx'::regclass`);
|
||||
console.log(`index valid after boot: ${check.rows[0]?.indisvalid}`);
|
||||
expect(String(after.map((r) => Object.values(r).map(String).join(',')))).toBe(
|
||||
String(before.map((r) => Object.values(r).map(String).join(','))),
|
||||
);
|
||||
expect(check.rows[0]?.indisvalid).toBe(true);
|
||||
});
|
||||
|
||||
describe('multi-range plan shape on a populated upgraded database', () => {
|
||||
const PERF = 'kv_ranges_perf';
|
||||
const SEP = String.fromCharCode(31);
|
||||
const BASE = 1456074443984486400n;
|
||||
let perf: IPostgresClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
const db = getDefaultPostgresClient();
|
||||
await db.query(`DROP TABLE IF EXISTS ${PERF}`);
|
||||
perf = new TableClient(db, PERF);
|
||||
await ensurePostgresKvSchema(perf);
|
||||
await db.query(
|
||||
`INSERT INTO ${PERF} (table_name, partition_key, row_key, row_data)
|
||||
SELECT 'push_subscriptions',
|
||||
format('{"__fluxer_type":"bigint","value":"%s"}%s{"__fluxer_type":"bigint","value":"%s"}', u, chr(31), s),
|
||||
format('{"__fluxer_type":"bigint","value":"%s"}%s{"__fluxer_type":"bigint","value":"%s"}', u, chr(31), s),
|
||||
jsonb_build_object(
|
||||
'user_id', jsonb_build_object('__fluxer_type', 'bigint', 'value', u::text),
|
||||
'subscription_id', jsonb_build_object('__fluxer_type', 'bigint', 'value', s::text),
|
||||
'endpoint', repeat('e', 300))
|
||||
FROM generate_series($1::bigint, $1::bigint + 19999) u, generate_series(1, 3) s`,
|
||||
[BASE.toString()],
|
||||
);
|
||||
await db.query(`ANALYZE ${PERF}`);
|
||||
}, 300_000);
|
||||
|
||||
it('emits a multi-range plan that is not slower than the tier-3 scan it replaces', async () => {
|
||||
const {PushSubscriptions} = await import('../Tables');
|
||||
const {buildCandidatePlan, planFragments} = await import('./PostgresKvQueryExecutor');
|
||||
const {getKvMeta} = await import('./CassandraMetaRegistry');
|
||||
const db = getDefaultPostgresClient();
|
||||
const explain = async (sql: string, params: Array<unknown>) => {
|
||||
const r = await db.query<Record<string, string>>(`EXPLAIN (ANALYZE, BUFFERS) ${sql}`, params);
|
||||
const plan = r.rows.map((row) => row['QUERY PLAN']!).join('\n');
|
||||
return {plan, ms: Number(/Execution Time: ([\d.]+)/.exec(plan)?.[1] ?? 'NaN')};
|
||||
};
|
||||
const cql = PushSubscriptions.selectCql({where: PushSubscriptions.where.in('user_id', 'user_ids')});
|
||||
const meta = getKvMeta(cql)!;
|
||||
const scan = await explain(
|
||||
`SELECT kv.row_key, kv.row_data FROM ${PERF} kv WHERE kv.table_name = $1 AND (kv.expires_at IS NULL OR kv.expires_at > now())`,
|
||||
['push_subscriptions'],
|
||||
);
|
||||
const deltas: Array<string> = [];
|
||||
for (const size of [5, 10, 25, 50, 100]) {
|
||||
const params = {user_ids: Array.from({length: size}, (_, i) => BASE + BigInt(i * 3))};
|
||||
const plan = buildCandidatePlan(meta, params as never);
|
||||
expect(plan.candidates.kind).toBe('ranges');
|
||||
const fragments = planFragments(plan.candidates);
|
||||
expect(fragments.predicate).not.toContain('unnest');
|
||||
const pushed = await explain(
|
||||
`SELECT kv.row_key, kv.row_data FROM ${PERF} kv WHERE kv.table_name = $1${fragments.predicate} AND (kv.expires_at IS NULL OR kv.expires_at > now())`,
|
||||
['push_subscriptions', ...fragments.params],
|
||||
);
|
||||
const verdict = `n=${size} pushdown=${pushed.ms.toFixed(2)}ms scan=${scan.ms.toFixed(2)}ms`;
|
||||
console.log(verdict);
|
||||
if (pushed.ms > scan.ms) deltas.push(`${verdict}\n${pushed.plan}`);
|
||||
}
|
||||
expect(deltas.join('\n')).toBe('');
|
||||
const overCap = buildCandidatePlan(meta, {
|
||||
user_ids: Array.from({length: 257}, (_, i) => BASE + BigInt(i * 3)),
|
||||
} as never);
|
||||
expect(overCap.candidates.kind).toBe('scan');
|
||||
expect(SEP).toBe('\u001f');
|
||||
}, 300_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('candidate plan shapes reached by real queries', () => {
|
||||
it('classifies the push fan-out and webhook lookups', async () => {
|
||||
const {PushSubscriptions, Webhooks} = await import('../Tables');
|
||||
const {buildCandidatePlan} = await import('./PostgresKvQueryExecutor');
|
||||
const {getKvMeta} = await import('./CassandraMetaRegistry');
|
||||
const cases = [
|
||||
{
|
||||
name: 'push_subscriptions IN(user_id) x100',
|
||||
cql: PushSubscriptions.selectCql({where: PushSubscriptions.where.in('user_id', 'user_ids')}),
|
||||
params: {user_ids: Array.from({length: 100}, (_, i) => BigInt(1000 + i))},
|
||||
},
|
||||
{
|
||||
name: 'webhooks IN(webhook_id) x100',
|
||||
cql: Webhooks.selectCql({where: Webhooks.where.in('webhook_id', 'webhook_ids')}),
|
||||
params: {webhook_ids: Array.from({length: 100}, (_, i) => BigInt(1000 + i))},
|
||||
},
|
||||
];
|
||||
for (const c of cases) {
|
||||
const meta = getKvMeta(c.cql)!;
|
||||
const plan = buildCandidatePlan(meta, c.params as never);
|
||||
const size =
|
||||
plan.candidates.kind === 'ranges'
|
||||
? plan.candidates.lowerBounds.length
|
||||
: plan.candidates.kind === 'rowKeys'
|
||||
? plan.candidates.rowKeys.length
|
||||
: 0;
|
||||
console.log(`${c.name} -> kind=${plan.candidates.kind} n=${size} exact=${plan.exact}`);
|
||||
}
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,561 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type IPostgresClient, type PostgresQueryable, quoteIdentifier} from '@pkgs/postgres/src/Client';
|
||||
import cassandra from 'cassandra-driver';
|
||||
import {getKvMeta, getTableMetadata} from '../CassandraMetaRegistry';
|
||||
import type {CassandraParams, ColumnName, KvQueryMeta, PreparedQuery, WhereExpr} from '../CassandraTypes';
|
||||
import {matchesWhere} from '../PostgresKvQueryExecutor';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type EqWhereExpr = Extract<WhereExpr<Row>, {kind: 'eq'}>;
|
||||
|
||||
interface StoredRow {
|
||||
row_key: string;
|
||||
row_data: unknown;
|
||||
}
|
||||
|
||||
interface PageState {
|
||||
offset: number;
|
||||
}
|
||||
|
||||
const VALUE_SEPARATOR = '\u001f';
|
||||
const ENCODED_TYPE_KEY = '__fluxer_type';
|
||||
const POSTGRES_KV_SCHEMA_LOCK_NAMESPACE = 0x46584b56;
|
||||
const POSTGRES_KV_SCHEMA_LOCK_TIMEOUT = '120s';
|
||||
|
||||
function normalizeCql(cql: string): string {
|
||||
return cql.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype;
|
||||
}
|
||||
|
||||
function encodeValue(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'bigint') return {[ENCODED_TYPE_KEY]: 'bigint', value: value.toString()};
|
||||
if (value instanceof Date) return {[ENCODED_TYPE_KEY]: 'date', value: value.toISOString()};
|
||||
if (Buffer.isBuffer(value)) return {[ENCODED_TYPE_KEY]: 'buffer', value: value.toString('base64')};
|
||||
if (value instanceof Set) return {[ENCODED_TYPE_KEY]: 'set', value: [...value.values()].map(encodeValue)};
|
||||
if (value instanceof Map) {
|
||||
return {
|
||||
[ENCODED_TYPE_KEY]: 'map',
|
||||
value: [...value.entries()].map(([key, entry]) => [encodeValue(key), encodeValue(entry)]),
|
||||
};
|
||||
}
|
||||
if (typeof value === 'object' && value.constructor?.name === 'LocalDate') {
|
||||
return {[ENCODED_TYPE_KEY]: 'local_date', value: value.toString()};
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(encodeValue);
|
||||
if (isPlainObject(value)) {
|
||||
const encoded: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
encoded[key] = encodeValue(entry);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(decodeValue);
|
||||
if (!isPlainObject(value)) return value;
|
||||
const encodedType = value[ENCODED_TYPE_KEY];
|
||||
if (encodedType === 'bigint') return BigInt(String(value.value));
|
||||
if (encodedType === 'date') return new Date(String(value.value));
|
||||
if (encodedType === 'buffer') return Buffer.from(String(value.value), 'base64');
|
||||
if (encodedType === 'set') return new Set(((value.value as Array<unknown>) ?? []).map(decodeValue));
|
||||
if (encodedType === 'map') {
|
||||
return new Map(
|
||||
((value.value as Array<[unknown, unknown]>) ?? []).map(([key, entry]) => [decodeValue(key), decodeValue(entry)]),
|
||||
);
|
||||
}
|
||||
if (encodedType === 'local_date') return cassandra.types.LocalDate.fromString(String(value.value));
|
||||
const decoded: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
decoded[key] = decodeValue(entry);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function encodeRow(row: Row): Record<string, unknown> {
|
||||
const encoded: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
encoded[key] = encodeValue(value);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
function decodeRow(value: unknown): Row {
|
||||
const decoded = decodeValue(value);
|
||||
if (!isPlainObject(decoded)) {
|
||||
throw new Error('Postgres KV row payload is not an object');
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function valueKey(value: unknown): string {
|
||||
return JSON.stringify(encodeValue(value));
|
||||
}
|
||||
|
||||
function keyFromColumns(columns: ReadonlyArray<string>, row: Row): string {
|
||||
return columns.map((column) => valueKey(row[column])).join(VALUE_SEPARATOR);
|
||||
}
|
||||
|
||||
function paramsRow(params: CassandraParams, columns: ReadonlyArray<string>): Row {
|
||||
const row: Row = {};
|
||||
for (const column of columns) {
|
||||
if (!(column in params)) {
|
||||
throw new Error(`Missing Postgres KV key parameter: ${column}`);
|
||||
}
|
||||
row[column] = params[column];
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function rowFromParams(meta: KvQueryMeta, params: CassandraParams): Row {
|
||||
const row: Row = {};
|
||||
for (const column of meta.table.columns) {
|
||||
if (column in params) {
|
||||
row[column] = params[column];
|
||||
}
|
||||
}
|
||||
if (meta.nowColumn) {
|
||||
row[meta.nowColumn] = new Date();
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function rowKey(meta: KvQueryMeta, row: Row): string {
|
||||
return keyFromColumns(meta.table.primaryKey as ReadonlyArray<string>, row);
|
||||
}
|
||||
|
||||
function partitionKey(meta: KvQueryMeta, row: Row): string {
|
||||
return keyFromColumns(meta.table.partitionKey as ReadonlyArray<string>, row);
|
||||
}
|
||||
|
||||
function rowKeyFromParams(meta: KvQueryMeta, params: CassandraParams): string {
|
||||
return rowKey(meta, paramsRow(params, (meta.pkColumns ?? meta.table.primaryKey) as ReadonlyArray<string>));
|
||||
}
|
||||
|
||||
function partitionKeyFromParams(meta: KvQueryMeta, params: CassandraParams): string {
|
||||
return partitionKey(meta, paramsRow(params, meta.table.partitionKey as ReadonlyArray<string>));
|
||||
}
|
||||
|
||||
function compareValues(left: unknown, right: unknown): number {
|
||||
if (typeof left === 'bigint' || typeof right === 'bigint') {
|
||||
const l = typeof left === 'bigint' ? left : BigInt(left as number | string);
|
||||
const r = typeof right === 'bigint' ? right : BigInt(right as number | string);
|
||||
if (l === r) return 0;
|
||||
return l < r ? -1 : 1;
|
||||
}
|
||||
const l = left instanceof Date ? left.getTime() : left?.constructor?.name === 'LocalDate' ? left.toString() : left;
|
||||
const r =
|
||||
right instanceof Date ? right.getTime() : right?.constructor?.name === 'LocalDate' ? right.toString() : right;
|
||||
if (Buffer.isBuffer(l) && Buffer.isBuffer(r)) return Buffer.compare(l, r);
|
||||
if (l === r) return 0;
|
||||
return (l as number | string) < (r as number | string) ? -1 : 1;
|
||||
}
|
||||
|
||||
function projectRow(row: Row, columns: ReadonlyArray<string> | undefined): Row {
|
||||
if (!columns) return {...row};
|
||||
const projected: Row = {};
|
||||
for (const column of columns) {
|
||||
projected[column] = row[column];
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
function sortRows(meta: KvQueryMeta, rows: Array<Row>): Array<Row> {
|
||||
if (meta.orderBy) {
|
||||
const direction = meta.orderBy.direction === 'DESC' ? -1 : 1;
|
||||
return rows.sort((left, right) => compareValues(left[meta.orderBy!.col], right[meta.orderBy!.col]) * direction);
|
||||
}
|
||||
return rows.sort((left, right) => {
|
||||
for (const column of meta.table.primaryKey) {
|
||||
const cmp = compareValues(left[column], right[column]);
|
||||
if (cmp !== 0) return cmp;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function equalityParam(where: ReadonlyArray<WhereExpr<Row>> | undefined, column: string): string | null {
|
||||
const clause = (where ?? []).find((entry) => entry.kind === 'eq' && entry.col === column);
|
||||
return clause && clause.kind === 'eq' ? clause.param : null;
|
||||
}
|
||||
|
||||
function inParam(where: ReadonlyArray<WhereExpr<Row>> | undefined, column: string): string | null {
|
||||
const clause = (where ?? []).find((entry) => entry.kind === 'in' && entry.col === column);
|
||||
return clause && clause.kind === 'in' ? clause.param : null;
|
||||
}
|
||||
|
||||
function fullRowKeysFromWhere(meta: KvQueryMeta, params: CassandraParams): Array<string> | null {
|
||||
const pk = meta.table.primaryKey as ReadonlyArray<string>;
|
||||
const eqParams = pk.map((column) => equalityParam(meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, column));
|
||||
if (eqParams.every((param) => param !== null)) {
|
||||
const row: Row = {};
|
||||
for (let i = 0; i < pk.length; i += 1) row[pk[i]!] = params[eqParams[i]!];
|
||||
return [rowKey(meta, row)];
|
||||
}
|
||||
if (pk.length === 1) {
|
||||
const param = inParam(meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, pk[0]!);
|
||||
if (param) {
|
||||
const values = params[param] as ReadonlyArray<unknown> | Set<unknown>;
|
||||
const haystack = values instanceof Set ? [...values] : values;
|
||||
return haystack.map((value) => rowKey(meta, {[pk[0]!]: value}));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasFullPartition(meta: KvQueryMeta): boolean {
|
||||
return meta.table.partitionKey.every((column) =>
|
||||
equalityParam(meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, column),
|
||||
);
|
||||
}
|
||||
|
||||
function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | null | undefined {
|
||||
const ttlParam = meta.ttlParamName;
|
||||
if (!ttlParam) return undefined;
|
||||
const ttlRaw = params[ttlParam];
|
||||
if (typeof ttlRaw !== 'number') {
|
||||
throw new Error(`TTL parameter ${ttlParam} must be a number`);
|
||||
}
|
||||
return new Date(Date.now() + ttlRaw * 1000);
|
||||
}
|
||||
|
||||
function encodePageState(pageState: PageState): string {
|
||||
return Buffer.from(JSON.stringify(pageState)).toString('base64url');
|
||||
}
|
||||
|
||||
function decodePageState(pageState: string | null | undefined): PageState {
|
||||
if (!pageState) return {offset: 0};
|
||||
const decoded = JSON.parse(Buffer.from(pageState, 'base64url').toString('utf8')) as PageState;
|
||||
if (!Number.isInteger(decoded.offset) || decoded.offset < 0) {
|
||||
throw new Error('Invalid Postgres KV page state');
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function parseRawMeta(cql: string): KvQueryMeta<Row> | null {
|
||||
const normalized = normalizeCql(cql).replace(/;$/, '');
|
||||
const update =
|
||||
/^UPDATE\s+([A-Za-z0-9_]+)(?:\s+USING\s+(?:TIMESTAMP|TTL)\s+:[A-Za-z0-9_]+)?\s+SET\s+(.+?)\s+WHERE\s+(.+)$/iu.exec(
|
||||
normalized,
|
||||
);
|
||||
if (update) {
|
||||
const table = tableSpec(update[1]!);
|
||||
const patchKeys = update[2]!.split(',').map((part) => {
|
||||
const [column, value] = part.trim().split(/\s*=\s*/u);
|
||||
if (!column || !value?.startsWith(':')) {
|
||||
throw new Error(`Postgres KV raw UPDATE only supports parameter assignments: ${cql}`);
|
||||
}
|
||||
return column;
|
||||
});
|
||||
const where = parseEqWhere(update[3]!, cql);
|
||||
return {
|
||||
action: 'patch',
|
||||
table,
|
||||
where,
|
||||
patchKeys,
|
||||
pkColumns: where.map((clause) => clause.col),
|
||||
};
|
||||
}
|
||||
const select =
|
||||
/^SELECT\s+(.+?)\s+FROM\s+([A-Za-z0-9_]+)(?:\s+WHERE\s+(.+?))?(?:\s+ALLOW\s+FILTERING)?(?:\s+LIMIT\s+(\d+))?$/iu.exec(
|
||||
normalized,
|
||||
);
|
||||
if (select) {
|
||||
const table = tableSpec(select[2]!);
|
||||
return {
|
||||
action: 'select',
|
||||
table,
|
||||
columns: select[1]!.split(',').map((part) => part.trim() as ColumnName<Row>),
|
||||
where: select[3] ? parseEqWhere(select[3], cql) : [],
|
||||
limit: select[4] ? Number.parseInt(select[4], 10) : undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tableSpec(tableName: string): KvQueryMeta<Row>['table'] {
|
||||
const table = getTableMetadata(tableName);
|
||||
if (!table) {
|
||||
throw new Error(`Postgres KV metadata is missing for table: ${tableName}`);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
function parseEqWhere(whereSql: string, cql: string): ReadonlyArray<EqWhereExpr> {
|
||||
return whereSql.split(/\s+AND\s+/iu).map((part) => {
|
||||
const match = /^\s*([A-Za-z0-9_]+)\s*=\s*:([A-Za-z0-9_]+)\s*$/u.exec(part.trim());
|
||||
if (!match) {
|
||||
throw new Error(`Postgres KV raw WHERE only supports equality predicates: ${cql}`);
|
||||
}
|
||||
return {kind: 'eq', col: match[1]! as ColumnName<Row>, param: match[2]!};
|
||||
});
|
||||
}
|
||||
|
||||
export async function legacyEnsurePostgresKvSchema(client: IPostgresClient): Promise<void> {
|
||||
const kvTable = client.kvTable();
|
||||
const table = quoteIdentifier(kvTable);
|
||||
await client.transaction(async (db) => {
|
||||
await db.query("SELECT set_config('statement_timeout', $1, true)", [POSTGRES_KV_SCHEMA_LOCK_TIMEOUT]);
|
||||
await db.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [POSTGRES_KV_SCHEMA_LOCK_NAMESPACE, kvTable]);
|
||||
await db.query("SELECT set_config('statement_timeout', '0', true)");
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${table} (
|
||||
table_name text NOT NULL,
|
||||
partition_key text NOT NULL,
|
||||
row_key text NOT NULL,
|
||||
row_data jsonb NOT NULL,
|
||||
expires_at timestamptz,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (table_name, row_key)
|
||||
)`);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_partition_row_idx`)} ON ${table} (table_name, partition_key, row_key)`,
|
||||
);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_row_key_c_idx`)} ON ${table} (table_name, row_key COLLATE "C")`,
|
||||
);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_expires_idx`)} ON ${table} (expires_at) WHERE expires_at IS NOT NULL`,
|
||||
);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_messages_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'messages'`,
|
||||
);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_message_reactions_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'message_reactions'`,
|
||||
);
|
||||
await db.query(`
|
||||
UPDATE ${table}
|
||||
SET partition_key = split_part(row_key, chr(31), 1) || chr(31) || split_part(row_key, chr(31), 2)
|
||||
WHERE table_name = 'messages'
|
||||
AND partition_key = row_key
|
||||
AND split_part(row_key, chr(31), 3) <> ''`);
|
||||
await db.query(`DROP INDEX IF EXISTS ${quoteIdentifier(`${kvTable}_partition_idx`)}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function legacyPruneExpiredPostgresKvRows(client: IPostgresClient, batchSize = 5000): Promise<number> {
|
||||
if (!Number.isInteger(batchSize) || batchSize <= 0) {
|
||||
throw new Error('Postgres KV prune batch size must be a positive integer');
|
||||
}
|
||||
const table = quoteIdentifier(client.kvTable());
|
||||
const result = await client.query(
|
||||
`
|
||||
WITH expired AS (
|
||||
SELECT table_name, row_key
|
||||
FROM ${table}
|
||||
WHERE expires_at IS NOT NULL AND expires_at <= now()
|
||||
ORDER BY expires_at
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
DELETE FROM ${table} kv
|
||||
USING expired
|
||||
WHERE kv.table_name = expired.table_name AND kv.row_key = expired.row_key`,
|
||||
[batchSize],
|
||||
);
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
|
||||
export class LegacyPostgresKvQueryExecutor {
|
||||
private readonly table: string;
|
||||
|
||||
constructor(private readonly client: IPostgresClient) {
|
||||
this.table = quoteIdentifier(client.kvTable());
|
||||
}
|
||||
|
||||
async executeQuery<T = Row, P extends CassandraParams = CassandraParams>(
|
||||
query: PreparedQuery<P>,
|
||||
db: PostgresQueryable = this.client,
|
||||
): Promise<Array<T>> {
|
||||
const meta = this.meta(query);
|
||||
switch (meta.action) {
|
||||
case 'select':
|
||||
return (await this.select(meta, query.params, db)) as Array<T>;
|
||||
case 'count':
|
||||
return [{count: (await this.select(meta, query.params, db)).length}] as Array<T>;
|
||||
case 'upsert':
|
||||
return (await this.upsert(meta, query.params, db)) as Array<T>;
|
||||
case 'insert':
|
||||
return (await this.upsert(meta, query.params, db)) as Array<T>;
|
||||
case 'patch':
|
||||
await this.patch(meta, query.params, db);
|
||||
return [];
|
||||
case 'delete':
|
||||
await this.delete(meta, query.params, db);
|
||||
return [];
|
||||
case 'batch':
|
||||
return [];
|
||||
default: {
|
||||
const _exhaustive: never = meta.action;
|
||||
throw new Error(`Unsupported Postgres KV action: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async executePagedQuery<T = Row, P extends CassandraParams = CassandraParams>(
|
||||
query: PreparedQuery<P>,
|
||||
options: {pageSize: number; pageState?: string | null},
|
||||
): Promise<{rows: Array<T>; pageState: string | null}> {
|
||||
const state = decodePageState(options.pageState);
|
||||
const rows = await this.executeQuery<T, P>(query);
|
||||
const pageRows = rows.slice(state.offset, state.offset + options.pageSize);
|
||||
const nextOffset = state.offset + pageRows.length;
|
||||
return {
|
||||
rows: pageRows,
|
||||
pageState: nextOffset < rows.length ? encodePageState({offset: nextOffset}) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async executeBatch(
|
||||
queries: Array<{query: string; params: object; meta?: KvQueryMeta}>,
|
||||
atomic = true,
|
||||
): Promise<void> {
|
||||
if (atomic) {
|
||||
await this.client.transaction(async (db) => {
|
||||
for (const query of queries) {
|
||||
await this.executeQuery({cql: query.query, params: query.params as CassandraParams, kvMeta: query.meta}, db);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (const query of queries) {
|
||||
await this.executeQuery({cql: query.query, params: query.params as CassandraParams, kvMeta: query.meta});
|
||||
}
|
||||
}
|
||||
|
||||
private meta(query: PreparedQuery): KvQueryMeta<Row> {
|
||||
const meta = (query.kvMeta ?? getKvMeta(query.cql) ?? parseRawMeta(query.cql)) as KvQueryMeta<Row> | null;
|
||||
if (!meta) {
|
||||
throw new Error(`Postgres KV does not understand query: ${query.cql}`);
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
private async candidates(
|
||||
meta: KvQueryMeta,
|
||||
params: CassandraParams,
|
||||
db: PostgresQueryable,
|
||||
): Promise<Array<StoredRow>> {
|
||||
const rowKeys = fullRowKeysFromWhere(meta, params);
|
||||
if (rowKeys) {
|
||||
const result = await db.query<StoredRow>(
|
||||
`SELECT row_key, row_data FROM ${this.table} WHERE table_name = $1 AND row_key = ANY($2::text[]) AND (expires_at IS NULL OR expires_at > now())`,
|
||||
[meta.table.name, rowKeys],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
if (hasFullPartition(meta)) {
|
||||
const result = await db.query<StoredRow>(
|
||||
`SELECT row_key, row_data FROM ${this.table} WHERE table_name = $1 AND partition_key = $2 AND (expires_at IS NULL OR expires_at > now())`,
|
||||
[meta.table.name, partitionKeyFromParams(meta, params)],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
const result = await db.query<StoredRow>(
|
||||
`SELECT row_key, row_data FROM ${this.table} WHERE table_name = $1 AND (expires_at IS NULL OR expires_at > now())`,
|
||||
[meta.table.name],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
private async select(meta: KvQueryMeta, params: CassandraParams, db: PostgresQueryable): Promise<Array<Row>> {
|
||||
let rows = (await this.candidates(meta, params, db))
|
||||
.map((stored) => decodeRow(stored.row_data))
|
||||
.filter((row) => matchesWhere(row, meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, params));
|
||||
rows = sortRows(meta, rows);
|
||||
if (typeof meta.limit === 'number') rows = rows.slice(0, meta.limit);
|
||||
return rows.map((row) => projectRow(row, meta.columns as ReadonlyArray<string> | undefined));
|
||||
}
|
||||
|
||||
private async upsert(meta: KvQueryMeta, params: CassandraParams, db: PostgresQueryable): Promise<Array<Row>> {
|
||||
const incoming = rowFromParams(meta, params);
|
||||
const key = rowKey(meta, incoming);
|
||||
const existing = await this.getRow(meta, key, db);
|
||||
if (meta.ifNotExists && existing) {
|
||||
return [{'[applied]': false}];
|
||||
}
|
||||
if (meta.ifNotExists) {
|
||||
await db.query(
|
||||
`DELETE FROM ${this.table} WHERE table_name = $1 AND row_key = $2 AND expires_at IS NOT NULL AND expires_at <= now()`,
|
||||
[meta.table.name, key],
|
||||
);
|
||||
}
|
||||
const next = {...(existing ?? {}), ...incoming};
|
||||
const expiresAt = ttlExpiresAt(meta, params) ?? null;
|
||||
const result = await db.query(
|
||||
`INSERT INTO ${this.table} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
ON CONFLICT (table_name, row_key)
|
||||
DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = EXCLUDED.row_data, expires_at = EXCLUDED.expires_at, updated_at = now()
|
||||
WHERE NOT $6`,
|
||||
[
|
||||
meta.table.name,
|
||||
partitionKey(meta, next),
|
||||
key,
|
||||
JSON.stringify(encodeRow(next)),
|
||||
expiresAt,
|
||||
meta.ifNotExists === true,
|
||||
],
|
||||
);
|
||||
if (meta.ifNotExists) {
|
||||
return [{'[applied]': result.rowCount === 1}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private async patch(meta: KvQueryMeta, params: CassandraParams, db: PostgresQueryable): Promise<void> {
|
||||
const key = rowKeyFromParams(meta, params);
|
||||
const stored = await this.getStoredRow(meta, key, db);
|
||||
const base = stored?.row ?? paramsRow(params, (meta.pkColumns ?? meta.table.primaryKey) as ReadonlyArray<string>);
|
||||
const next = {...base};
|
||||
for (const column of meta.patchKeys ?? []) {
|
||||
next[column] = column in params ? params[column] : null;
|
||||
}
|
||||
const ttl = ttlExpiresAt(meta, params);
|
||||
const expiresAt = ttl === undefined ? (stored?.expiresAt ?? null) : ttl;
|
||||
await db.query(
|
||||
`INSERT INTO ${this.table} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
ON CONFLICT (table_name, row_key)
|
||||
DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = EXCLUDED.row_data, expires_at = EXCLUDED.expires_at, updated_at = now()`,
|
||||
[meta.table.name, partitionKey(meta, next), key, JSON.stringify(encodeRow(next)), expiresAt ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
private async delete(meta: KvQueryMeta, params: CassandraParams, db: PostgresQueryable): Promise<void> {
|
||||
const rows = await this.candidates(meta, params, db);
|
||||
const matchingKeys = rows
|
||||
.filter((stored) =>
|
||||
matchesWhere(decodeRow(stored.row_data), meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, params),
|
||||
)
|
||||
.map((stored) => stored.row_key);
|
||||
if (matchingKeys.length === 0) return;
|
||||
await db.query(`DELETE FROM ${this.table} WHERE table_name = $1 AND row_key = ANY($2::text[])`, [
|
||||
meta.table.name,
|
||||
matchingKeys,
|
||||
]);
|
||||
}
|
||||
|
||||
private async getStoredRow(
|
||||
meta: KvQueryMeta,
|
||||
key: string,
|
||||
db: PostgresQueryable,
|
||||
): Promise<{row: Row; expiresAt: Date | null} | null> {
|
||||
const result = await db.query<StoredRow & {expires_at: Date | null}>(
|
||||
`SELECT row_key, row_data, expires_at FROM ${this.table} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
|
||||
[meta.table.name, key],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? {row: decodeRow(row.row_data), expiresAt: row.expires_at ?? null} : null;
|
||||
}
|
||||
|
||||
private async getRow(meta: KvQueryMeta, key: string, db: PostgresQueryable): Promise<Row | null> {
|
||||
return (await this.getStoredRow(meta, key, db))?.row ?? null;
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
|
||||
}
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
|
||||
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
|
||||
const DEFAULT_API_CLIENT_BASE_URL = 'https://api.fluxer.app';
|
||||
const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/fluxerapp/fluxer/releases/download';
|
||||
const GITHUB_RELEASE_MARKER_DIRECTORY = 'github-releases';
|
||||
@@ -987,7 +988,13 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
if (normalized.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!DOWNLOAD_KEY_ALLOWED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizePlatformArchKey(key: string): string | null {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {MemeID, UserID} from '../BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
|
||||
import {Db} from '../database/CassandraTypes';
|
||||
import type {FavoriteMemeRow} from '../database/types/UserTypes';
|
||||
import {FavoriteMeme} from '../models/FavoriteMeme';
|
||||
import {FavoriteMemes, FavoriteMemesByMemeId} from '../Tables';
|
||||
@@ -99,6 +100,10 @@ export class FavoriteMemeRepository extends IFavoriteMemeRepository {
|
||||
return new FavoriteMeme(memeRow);
|
||||
}
|
||||
|
||||
async updatePlaceholder(userId: UserID, memeId: MemeID, placeholder: string): Promise<void> {
|
||||
await fetchOne(FavoriteMemes.patchByPk({user_id: userId, meme_id: memeId}, {placeholder: Db.set(placeholder)}));
|
||||
}
|
||||
|
||||
async delete(userId: UserID, memeId: MemeID): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(FavoriteMemes.deleteByPk({user_id: userId, meme_id: memeId}));
|
||||
|
||||
@@ -34,6 +34,8 @@ export abstract class IFavoriteMemeRepository {
|
||||
|
||||
abstract update(userId: UserID, memeId: MemeID, data: CreateFavoriteMemeParams): Promise<FavoriteMeme>;
|
||||
|
||||
abstract updatePlaceholder(userId: UserID, memeId: MemeID, placeholder: string): Promise<void>;
|
||||
|
||||
abstract delete(userId: UserID, memeId: MemeID): Promise<void>;
|
||||
|
||||
abstract deleteAllByUserId(userId: UserID): Promise<void>;
|
||||
|
||||
@@ -262,7 +262,7 @@ export function GuildMemberController(app: HonoApp) {
|
||||
const userId = ctx.get('user').id;
|
||||
const targetId = createUserID(user_id);
|
||||
const guildId = createGuildID(guild_id);
|
||||
const {delete_message_days, reason, ban_duration_seconds} = ctx.req.valid('json');
|
||||
const {delete_message_days, delete_message_seconds, reason, ban_duration_seconds} = ctx.req.valid('json');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const effectiveReason = reason ?? auditLogReason ?? undefined;
|
||||
await ctx.get('guildService').moderation.banMember(
|
||||
@@ -271,6 +271,7 @@ export function GuildMemberController(app: HonoApp) {
|
||||
guildId,
|
||||
targetId,
|
||||
deleteMessageDays: delete_message_days,
|
||||
deleteMessageSeconds: delete_message_seconds,
|
||||
reason: effectiveReason,
|
||||
banDurationSeconds: ban_duration_seconds,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
|
||||
import type {UserID} from '../../BrandedTypes';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
|
||||
export const ELEVATED_MFA_PERMISSIONS =
|
||||
const ELEVATED_MFA_PERMISSIONS =
|
||||
Permissions.KICK_MEMBERS |
|
||||
Permissions.BAN_MEMBERS |
|
||||
Permissions.ADMINISTRATOR |
|
||||
|
||||
@@ -30,6 +30,8 @@ import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAg
|
||||
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||
import {GuildMemberSearchIndexService} from './member/GuildMemberSearchIndexService';
|
||||
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
|
||||
export class GuildModerationService {
|
||||
private readonly searchIndexService: GuildMemberSearchIndexService;
|
||||
|
||||
@@ -64,13 +66,23 @@ export class GuildModerationService {
|
||||
targetId: UserID;
|
||||
guildId: GuildID;
|
||||
deleteMessageDays?: number;
|
||||
deleteMessageSeconds?: number;
|
||||
reason?: string | null;
|
||||
banDurationSeconds?: number;
|
||||
skipGuildAuditLog?: boolean;
|
||||
},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds, skipGuildAuditLog} = params;
|
||||
const {
|
||||
userId,
|
||||
guildId,
|
||||
targetId,
|
||||
deleteMessageDays,
|
||||
deleteMessageSeconds,
|
||||
reason,
|
||||
banDurationSeconds,
|
||||
skipGuildAuditLog,
|
||||
} = params;
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
if (userId === targetId) throw new UnknownGuildMemberError();
|
||||
const targetUser = await this.userRepository.findUnique(targetId);
|
||||
@@ -82,11 +94,13 @@ export class GuildModerationService {
|
||||
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId: targetId});
|
||||
if (!canManage) throw new MissingPermissionsError();
|
||||
}
|
||||
if (deleteMessageDays && deleteMessageDays > 0) {
|
||||
const effectiveDeleteMessageSeconds =
|
||||
deleteMessageSeconds ?? (deleteMessageDays !== undefined ? deleteMessageDays * SECONDS_PER_DAY : undefined);
|
||||
if (effectiveDeleteMessageSeconds && effectiveDeleteMessageSeconds > 0) {
|
||||
await this.workerService.addJob('deleteUserMessagesInGuildByTime', {
|
||||
guildId: guildId.toString(),
|
||||
userId: targetId.toString(),
|
||||
days: deleteMessageDays,
|
||||
seconds: effectiveDeleteMessageSeconds,
|
||||
});
|
||||
}
|
||||
const targetIp = isIpBanExempt(targetUser.lastActiveIp) ? null : targetUser.lastActiveIp || null;
|
||||
|
||||
@@ -308,7 +308,7 @@ export class GuildRoleService {
|
||||
position?: number;
|
||||
}>;
|
||||
},
|
||||
_auditLogReason?: string | null,
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, updates} = params;
|
||||
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
|
||||
@@ -319,7 +319,7 @@ export class GuildRoleService {
|
||||
throw new ResourceLockedError();
|
||||
}
|
||||
try {
|
||||
await this.updateRolePositionsByList({userId, guildId, updates});
|
||||
await this.updateRolePositionsByList({userId, guildId, updates, auditLogReason: auditLogReason ?? null});
|
||||
} finally {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
}
|
||||
@@ -345,7 +345,7 @@ export class GuildRoleService {
|
||||
hoistPosition: number;
|
||||
}>;
|
||||
},
|
||||
_auditLogReason?: string | null,
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, updates} = params;
|
||||
const {checkPermission, guildData} = await this.getGuildAuthenticated({userId, guildId});
|
||||
@@ -403,6 +403,7 @@ export class GuildRoleService {
|
||||
}
|
||||
if (changedRoles.length > 0) {
|
||||
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
|
||||
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
|
||||
}
|
||||
} finally {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
@@ -414,7 +415,7 @@ export class GuildRoleService {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
},
|
||||
_auditLogReason?: string | null,
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId} = params;
|
||||
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
|
||||
@@ -426,6 +427,7 @@ export class GuildRoleService {
|
||||
}
|
||||
try {
|
||||
const allRoles = await this.guildRepository.listRoles(guildId);
|
||||
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
|
||||
const changedRoles: Array<GuildRole> = [];
|
||||
for (const role of allRoles) {
|
||||
if (role.hoistPosition === null) continue;
|
||||
@@ -441,6 +443,7 @@ export class GuildRoleService {
|
||||
}
|
||||
if (changedRoles.length > 0) {
|
||||
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
|
||||
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
|
||||
}
|
||||
} finally {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
@@ -559,8 +562,9 @@ export class GuildRoleService {
|
||||
roleId: RoleID;
|
||||
position?: number;
|
||||
}>;
|
||||
auditLogReason?: string | null;
|
||||
}): Promise<void> {
|
||||
const {userId, guildId, updates} = params;
|
||||
const {userId, guildId, updates, auditLogReason} = params;
|
||||
const {guildData} = await this.getGuildAuthenticated({userId, guildId});
|
||||
const allRoles = await this.guildRepository.listRoles(guildId);
|
||||
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
|
||||
@@ -625,6 +629,7 @@ export class GuildRoleService {
|
||||
});
|
||||
if (changedRoles.length > 0) {
|
||||
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
|
||||
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,6 +704,30 @@ export class GuildRoleService {
|
||||
return newRoles;
|
||||
}
|
||||
|
||||
private async recordRolePositionAuditLogs(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
roleMap: Map<RoleID, GuildRole>;
|
||||
changedRoles: Array<GuildRole>;
|
||||
auditLogReason?: string | null;
|
||||
}): Promise<void> {
|
||||
const {guildId, userId, roleMap, changedRoles, auditLogReason} = params;
|
||||
for (const role of changedRoles) {
|
||||
const oldRole = roleMap.get(role.id);
|
||||
await this.recordAuditLog({
|
||||
guildId,
|
||||
userId,
|
||||
action: AuditLogActionType.ROLE_UPDATE,
|
||||
targetId: role.id,
|
||||
auditLogReason: auditLogReason ?? null,
|
||||
changes: this.guildAuditLogService.computeChanges(
|
||||
oldRole ? this.serializeRoleForAudit(oldRole) : null,
|
||||
this.serializeRoleForAudit(role),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private serializeRoleForAudit(role: GuildRole): Record<string, unknown> {
|
||||
return {
|
||||
role_id: role.id.toString(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
@@ -22,6 +22,7 @@ import {buildMessageSearchFilters} from '../../search/BuildMessageSearchFilters'
|
||||
import {channelNeedsReindexing} from '../../search/ChannelIndexingUtils';
|
||||
import {MessageSearchResponseMapper} from '../../search/MessageSearchResponseMapper';
|
||||
import {searchExistingMessages} from '../../search/MessageSearchResultReconciler';
|
||||
import {channelRequiresAgeVerification} from '../../search/SearchNsfwUtils';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {canUserAccessNsfwContent} from '../../utils/AgeUtils';
|
||||
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
|
||||
@@ -84,6 +85,7 @@ export class GuildSearchService {
|
||||
}
|
||||
}
|
||||
const canIncludeNsfw = includeNsfwRequested && canUserAccessNsfw;
|
||||
const guildNsfw = guildData?.nsfw ?? false;
|
||||
const channels = await this.channelRepository.listChannels(channelIds);
|
||||
const channelMap = new Map<string, Channel>();
|
||||
for (const channel of channels) {
|
||||
@@ -96,9 +98,10 @@ export class GuildSearchService {
|
||||
throw InputValidationError.fromCode('channel_ids', ValidationErrorCodes.ALL_CHANNELS_MUST_BELONG_TO_GUILD);
|
||||
}
|
||||
}
|
||||
const categoryLookup = await this.buildParentCategoryLookup(channelMap);
|
||||
const nsfwFilteredIds = channelIds.filter((id) => {
|
||||
const channel = channelMap.get(id.toString())!;
|
||||
return !(channel.isNsfw && !canIncludeNsfw);
|
||||
return !(channelRequiresAgeVerification(channel, categoryLookup, guildNsfw) && !canIncludeNsfw);
|
||||
});
|
||||
const permissionResults = await mapWithConcurrency(nsfwFilteredIds, PERMISSION_CHECK_CONCURRENCY, (channelId) =>
|
||||
this.gatewayService.checkPermission({
|
||||
@@ -188,7 +191,7 @@ export class GuildSearchService {
|
||||
if (!searchService) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} =
|
||||
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} =
|
||||
await this.collectAccessibleGuildChannels(userId);
|
||||
if (unindexedChannelIds.size > 0) {
|
||||
await this.queueIndexingChannels(unindexedChannelIds);
|
||||
@@ -219,7 +222,7 @@ export class GuildSearchService {
|
||||
if (guildIsAgeRestricted) {
|
||||
return canIncludeNsfw;
|
||||
}
|
||||
if (channel.isNsfw) {
|
||||
if (channelRequiresAgeVerification(channel, parentCategories, false)) {
|
||||
return canIncludeNsfw;
|
||||
}
|
||||
return true;
|
||||
@@ -259,6 +262,24 @@ export class GuildSearchService {
|
||||
};
|
||||
}
|
||||
|
||||
private async buildParentCategoryLookup(channelMap: Map<string, Channel>): Promise<Map<string, Channel>> {
|
||||
const lookup = new Map<string, Channel>(channelMap);
|
||||
const missingParentIds: Array<ChannelID> = [];
|
||||
for (const channel of channelMap.values()) {
|
||||
const parentId = channel.parentId;
|
||||
if (parentId != null && !lookup.has(parentId.toString())) {
|
||||
missingParentIds.push(parentId);
|
||||
}
|
||||
}
|
||||
if (missingParentIds.length > 0) {
|
||||
const parents = await this.channelRepository.listChannels(missingParentIds);
|
||||
for (const parent of parents) {
|
||||
lookup.set(parent.id.toString(), parent);
|
||||
}
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private async getCanUserAccessNsfw(userId: UserID): Promise<boolean> {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
@@ -284,11 +305,13 @@ export class GuildSearchService {
|
||||
accessibleChannels: Map<string, Channel>;
|
||||
unindexedChannelIds: Set<string>;
|
||||
guildNsfwLevels: Map<string, number>;
|
||||
parentCategories: Map<string, Channel>;
|
||||
}> {
|
||||
const guildIds = await this.userRepository.getUserGuildIds(userId);
|
||||
const accessibleChannels = new Map<string, Channel>();
|
||||
const unindexedChannelIds = new Set<string>();
|
||||
const guildNsfwLevels = new Map<string, number>();
|
||||
const parentCategories = new Map<string, Channel>();
|
||||
const permissionChecks: Array<{
|
||||
channel: Channel;
|
||||
guildId: GuildID;
|
||||
@@ -304,6 +327,9 @@ export class GuildSearchService {
|
||||
}
|
||||
const viewableChannelIds = new Set(viewableChannels.map((channelId) => channelId.toString()));
|
||||
for (const channel of guildChannels) {
|
||||
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
||||
parentCategories.set(channel.id.toString(), channel);
|
||||
}
|
||||
if (viewableChannelIds.has(channel.id.toString())) {
|
||||
permissionChecks.push({channel, guildId});
|
||||
}
|
||||
@@ -331,6 +357,6 @@ export class GuildSearchService {
|
||||
unindexedChannelIds.add(channelIdStr);
|
||||
}
|
||||
}
|
||||
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels};
|
||||
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ import {Guild} from '../../../models/Guild';
|
||||
import type {User} from '../../../models/User';
|
||||
import {getGuildSearchService} from '../../../SearchFactory';
|
||||
import type {GuildDiscoveryContext} from '../../../search/guild/GuildSearchSerializer';
|
||||
import {deleteGuildMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
|
||||
import {deleteChannelMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
|
||||
import {Channels, ChannelsByGuild, GuildMembers, GuildMembersByUserId, GuildRoles, Guilds} from '../../../Tables';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {mapUserSettingsToResponse} from '../../../user/UserMappers';
|
||||
@@ -858,7 +858,11 @@ export class GuildOperationsService {
|
||||
await Promise.all(webhooks.map((webhook) => this.webhookRepository.delete(webhook.id)));
|
||||
const channels = await this.channelRepository.listGuildChannels(guildId);
|
||||
await Promise.all(channels.map((channel) => this.channelRepository.deleteAllChannelMessages(channel.id)));
|
||||
await deleteGuildMessageSearchDocuments(guildId, {context: {source: 'guild_delete'}});
|
||||
await Promise.all(
|
||||
channels.map((channel) =>
|
||||
deleteChannelMessageSearchDocuments(channel.id, {context: {source: 'guild_delete'}}),
|
||||
),
|
||||
);
|
||||
await Promise.all(channels.map((channel) => this.channelService.attachments.purgeChannelAttachments(channel)));
|
||||
const discoveryRow = await this.discoveryRepository.findByGuildId(guildId);
|
||||
if (discoveryRow) {
|
||||
|
||||
@@ -37,8 +37,11 @@ export class GuildMemberAuthService {
|
||||
if (!canManage) throw new MissingPermissionsError();
|
||||
};
|
||||
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
|
||||
const hasPermission = async (permission: bigint) =>
|
||||
this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
const hasPermission = async (permission: bigint) => {
|
||||
const allowed = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
if (allowed) enforceGuildMfa(permission);
|
||||
return allowed;
|
||||
};
|
||||
const canManageRoles = async (targetUserId: UserID, targetRoleId: RoleID) =>
|
||||
this.gatewayService.canManageRoles({guildId, userId, targetUserId, roleId: targetRoleId});
|
||||
return {
|
||||
|
||||
@@ -42,7 +42,7 @@ export class GuildMemberRoleService {
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {userId, targetId, guildId, roleId} = params;
|
||||
const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
|
||||
const {guildData, hasPermission, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
await this.validationService.validateRoleAssignment({
|
||||
@@ -51,6 +51,7 @@ export class GuildMemberRoleService {
|
||||
userId,
|
||||
targetId,
|
||||
roleId,
|
||||
hasPermission,
|
||||
canManageRoles,
|
||||
});
|
||||
if (targetMember.roleIds.has(roleId)) return;
|
||||
@@ -75,7 +76,7 @@ export class GuildMemberRoleService {
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {userId, targetId, guildId, roleId} = params;
|
||||
const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
|
||||
const {guildData, hasPermission, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
|
||||
const targetMember = await this.guildRepository.getMember(guildId, targetId);
|
||||
if (!targetMember) throw new UnknownGuildMemberError();
|
||||
await this.validationService.validateRoleAssignment({
|
||||
@@ -84,6 +85,7 @@ export class GuildMemberRoleService {
|
||||
userId,
|
||||
targetId,
|
||||
roleId,
|
||||
hasPermission,
|
||||
canManageRoles,
|
||||
});
|
||||
if (!targetMember.roleIds.has(roleId)) return;
|
||||
|
||||
@@ -74,9 +74,10 @@ export class GuildMemberValidationService {
|
||||
userId: UserID;
|
||||
targetId: UserID;
|
||||
roleId: RoleID;
|
||||
hasPermission: (permission: bigint) => Promise<boolean>;
|
||||
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
|
||||
}): Promise<void> {
|
||||
const {guildData, guildId, userId, targetId, roleId, canManageRoles} = params;
|
||||
const {guildData, guildId, userId, targetId, roleId, hasPermission, canManageRoles} = params;
|
||||
ensureNotEveryoneRole(roleId, guildId, 'role_id');
|
||||
if (guildData && guildData.owner_id === userId.toString()) {
|
||||
const role = await this.guildRepository.getRole(roleId, guildId);
|
||||
@@ -84,6 +85,9 @@ export class GuildMemberValidationService {
|
||||
throw new UnknownGuildRoleError();
|
||||
}
|
||||
} else {
|
||||
if (!(await hasPermission(Permissions.MANAGE_ROLES))) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
if (!(await canManageRoles(targetId, roleId))) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface IStorageService {
|
||||
key: string;
|
||||
uploadId: string;
|
||||
partNumber: number;
|
||||
contentLength?: number;
|
||||
expiresIn?: number;
|
||||
}): Promise<string>;
|
||||
purgeBucket(bucket: string): Promise<void>;
|
||||
|
||||
@@ -296,12 +296,14 @@ export class StorageService implements IStorageService {
|
||||
key,
|
||||
uploadId,
|
||||
partNumber,
|
||||
contentLength,
|
||||
expiresIn = seconds('1 hour'),
|
||||
}: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
uploadId: string;
|
||||
partNumber: number;
|
||||
contentLength?: number;
|
||||
expiresIn?: number;
|
||||
}): Promise<string> {
|
||||
const command = new UploadPartCommand({
|
||||
@@ -309,6 +311,7 @@ export class StorageService implements IStorageService {
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
PartNumber: partNumber,
|
||||
ContentLength: contentLength,
|
||||
});
|
||||
return getSignedUrl(this.presignClient, command, {expiresIn});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
|
||||
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory';
|
||||
@@ -53,7 +54,9 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
|
||||
if (!captchaConfig.enabled && !(Config.dev.testModeEnabled && Config.captcha.enabled)) return;
|
||||
const user = ctx.get('user') as User | undefined;
|
||||
if (accountPolicyContactHasCapability(user?.email, 'captcha_exempt')) return;
|
||||
if (userHasCaptchaExemptFlag(user)) return;
|
||||
if (await requestContactHasCaptchaExemption(ctx.req.raw)) return;
|
||||
if (await requestUserHasCaptchaExemptFlag(ctx)) return;
|
||||
const token = ctx.req.header('x-captcha-token');
|
||||
if (!token) {
|
||||
throw new CaptchaRequiredError();
|
||||
@@ -83,6 +86,23 @@ async function requestContactHasCaptchaExemption(request: Request): Promise<bool
|
||||
}
|
||||
}
|
||||
|
||||
function userHasCaptchaExemptFlag(user: User | null | undefined): boolean {
|
||||
return user != null && (user.flags & UserFlags.APP_STORE_REVIEWER) !== 0n;
|
||||
}
|
||||
|
||||
async function requestUserHasCaptchaExemptFlag(ctx: Context<HonoEnv>): Promise<boolean> {
|
||||
try {
|
||||
const body = (await ctx.req.raw.clone().json()) as unknown;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
|
||||
const email = (body as Record<string, unknown>).email;
|
||||
if (typeof email !== 'string') return false;
|
||||
const user = await ctx.get('userRepository').findByEmail(email);
|
||||
return userHasCaptchaExemptFlag(user);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const CaptchaMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||
await verifyCaptchaToken(ctx);
|
||||
await next();
|
||||
|
||||
@@ -52,7 +52,11 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean {
|
||||
function getClientIdentifier(ctx: Context<HonoEnv>): string {
|
||||
const user = ctx.get('user');
|
||||
if (user?.id) {
|
||||
return `user:${user.id}`;
|
||||
const tokenType = ctx.get('authTokenType') ?? 'session';
|
||||
if (tokenType === 'bearer') {
|
||||
return `user:${user.id}:bearer:${ctx.get('oauthBearerApplicationId') ?? 'unknown'}`;
|
||||
}
|
||||
return `user:${user.id}:${tokenType}`;
|
||||
}
|
||||
const ip = extractClientIp(ctx.req.raw, {
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
|
||||
@@ -254,7 +254,7 @@ function normalizeStoredSyncedPreferences(raw: string | null | undefined): strin
|
||||
try {
|
||||
decodeSyncedPreferences(raw);
|
||||
} catch {
|
||||
return '';
|
||||
return raw;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -316,7 +316,10 @@ export class OAuth2RequestService {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
Logger.info({redirectTo}, 'OAuth2 consent: returning redirect URL');
|
||||
Logger.info(
|
||||
{clientId: params.body.client_id.toString(), userId: params.userId.toString()},
|
||||
'OAuth2 consent: returning redirect URL',
|
||||
);
|
||||
return {redirect_to: redirectTo};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
export const ACCESS_TOKEN_TTL_SECONDS = seconds('7 days');
|
||||
export const AUTHORIZATION_CODE_TTL_SECONDS = seconds('10 minutes');
|
||||
|
||||
@@ -19,13 +19,17 @@ import {
|
||||
OAuth2RefreshTokens,
|
||||
OAuth2RefreshTokensByUser,
|
||||
} from '../../Tables';
|
||||
import {ACCESS_TOKEN_TTL_SECONDS} from '../OAuth2TokenConstants';
|
||||
import {ACCESS_TOKEN_TTL_SECONDS, AUTHORIZATION_CODE_TTL_SECONDS} from '../OAuth2TokenConstants';
|
||||
import type {IOAuth2TokenRepository} from './IOAuth2TokenRepository';
|
||||
|
||||
function isAccessTokenExpired(createdAt: Date): boolean {
|
||||
return Date.now() - createdAt.getTime() > ACCESS_TOKEN_TTL_SECONDS * 1000;
|
||||
}
|
||||
|
||||
function isAuthorizationCodeExpired(createdAt: Date): boolean {
|
||||
return Date.now() - createdAt.getTime() > AUTHORIZATION_CODE_TTL_SECONDS * 1000;
|
||||
}
|
||||
|
||||
const SELECT_AUTHORIZATION_CODE = OAuth2AuthorizationCodes.selectCql({
|
||||
where: OAuth2AuthorizationCodes.where.eq('code'),
|
||||
});
|
||||
@@ -46,13 +50,19 @@ const SELECT_REFRESH_TOKENS_BY_USER = OAuth2RefreshTokensByUser.selectCql({
|
||||
|
||||
export class OAuth2TokenRepository implements IOAuth2TokenRepository {
|
||||
async createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode> {
|
||||
await upsertOne(OAuth2AuthorizationCodes.insert(data));
|
||||
await upsertOne(OAuth2AuthorizationCodes.insertWithTtl(data, AUTHORIZATION_CODE_TTL_SECONDS));
|
||||
return new OAuth2AuthorizationCode(data);
|
||||
}
|
||||
|
||||
async getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null> {
|
||||
const row = await fetchOne<OAuth2AuthorizationCodeRow>(SELECT_AUTHORIZATION_CODE, {code});
|
||||
return row ? new OAuth2AuthorizationCode(row) : null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
if (isAuthorizationCodeExpired(row.created_at)) {
|
||||
return null;
|
||||
}
|
||||
return new OAuth2AuthorizationCode(row);
|
||||
}
|
||||
|
||||
async deleteAuthorizationCode(code: string): Promise<void> {
|
||||
|
||||
@@ -30823,7 +30823,14 @@
|
||||
"minimum": 0,
|
||||
"maximum": 7,
|
||||
"format": "int32",
|
||||
"description": "Number of days of messages to delete from the banned user (0-7)"
|
||||
"description": "Number of days of messages to delete from the banned user (0-7). Deprecated in favor of delete_message_seconds."
|
||||
},
|
||||
"delete_message_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 604800,
|
||||
"format": "int32",
|
||||
"description": "Number of seconds of messages to delete for the banned user (0-604800, default 0)"
|
||||
},
|
||||
"reason": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
|
||||
@@ -143,7 +143,7 @@ function buildApnsHeaders(params: {
|
||||
'apns-topic': params.topic,
|
||||
'apns-push-type': isClear ? APNS_PUSH_TYPE_BACKGROUND : APNS_PUSH_TYPE_ALERT,
|
||||
'apns-priority': isClear ? '5' : '10',
|
||||
'apns-expiration': String(Math.floor(Date.now() / 1000) + (isClear ? 300 : 86400)),
|
||||
'apns-expiration': String(Math.floor(Date.now() / 1000) + (isClear ? 3600 : 86400)),
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
if (collapseId && Buffer.byteLength(collapseId) <= 64) {
|
||||
@@ -155,18 +155,11 @@ function buildApnsHeaders(params: {
|
||||
function buildApnsPayload(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
if (isClearNotificationPayload(payload)) {
|
||||
const data = isRecord(payload.data) ? payload.data : {};
|
||||
const badge = normalizeBadgeCount(data.badge_count);
|
||||
const aps: Record<string, unknown> = {
|
||||
'content-available': 1,
|
||||
};
|
||||
if (badge !== undefined) {
|
||||
aps.badge = badge;
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
type: 'notification_clear',
|
||||
action: 'clear_channel',
|
||||
aps,
|
||||
aps: {'content-available': 1},
|
||||
};
|
||||
}
|
||||
const data = isRecord(payload.data) ? payload.data : {};
|
||||
@@ -177,16 +170,18 @@ function buildApnsPayload(payload: Record<string, unknown>): Record<string, unkn
|
||||
const channelId = optionalString(data.channel_id);
|
||||
const threadId =
|
||||
optionalString(data.notification_tag) ?? (channelId ? `channel:${channelId}` : undefined) ?? 'fluxer-message';
|
||||
const imageUrl = firstString([payload.image_url, notification.image, notification.icon, payload.icon]);
|
||||
const imageUrl = firstString([payload.image_url, notification.image]);
|
||||
const aps: Record<string, unknown> = {
|
||||
alert: {title, body},
|
||||
sound: APNS_DEFAULT_SOUND,
|
||||
badge,
|
||||
'thread-id': threadId,
|
||||
category: APNS_CATEGORY_MESSAGE,
|
||||
'interruption-level': 'active',
|
||||
'relevance-score': 0.5,
|
||||
};
|
||||
if (badge !== undefined) {
|
||||
aps.badge = badge;
|
||||
}
|
||||
if (imageUrl) {
|
||||
aps['mutable-content'] = 1;
|
||||
}
|
||||
@@ -292,13 +287,13 @@ function isPermanentApnsFailure(statusCode: number, reason: string): boolean {
|
||||
return reason === 'Unregistered';
|
||||
}
|
||||
|
||||
function normalizeBadgeCount(value: unknown): number {
|
||||
function normalizeBadgeCount(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
|
||||
return Number.isFinite(parsed) ? Math.max(0, parsed) : undefined;
|
||||
}
|
||||
return 0;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstString(values: Array<unknown>): string | undefined {
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('ApnsPushService', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
it('builds silent APNs clear payloads with badge when badge_count is present', () => {
|
||||
it('builds silent APNs clear payloads as pure background pushes', () => {
|
||||
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
|
||||
type: 'notification_clear',
|
||||
action: 'clear_channel',
|
||||
@@ -56,11 +56,11 @@ describe('ApnsPushService', () => {
|
||||
badge_count: 0,
|
||||
aps: {
|
||||
'content-available': 1,
|
||||
badge: 0,
|
||||
},
|
||||
});
|
||||
expect(payload.aps).not.toHaveProperty('alert');
|
||||
expect(payload.aps).not.toHaveProperty('sound');
|
||||
expect(payload.aps).not.toHaveProperty('badge');
|
||||
});
|
||||
it('uses APNs push-type and priority headers that match alert versus background delivery', () => {
|
||||
const alertHeaders = ApnsPushServiceTestHooks.buildApnsHeaders({
|
||||
@@ -94,6 +94,43 @@ describe('ApnsPushService', () => {
|
||||
'apns-priority': '5',
|
||||
'apns-collapse-id': 'channel:123',
|
||||
});
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(Number(clearHeaders['apns-expiration']) - now).toBeGreaterThan(1800);
|
||||
});
|
||||
|
||||
it('omits the badge entirely when no usable badge count is supplied', () => {
|
||||
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
|
||||
tag: 'channel:123:456',
|
||||
data: {channel_id: '123', message_id: '456'},
|
||||
notification: {title: 'Alice', body: 'Hello'},
|
||||
});
|
||||
expect(payload.aps).not.toHaveProperty('badge');
|
||||
const unparseable = ApnsPushServiceTestHooks.buildApnsPayload({
|
||||
tag: 'channel:123:456',
|
||||
data: {channel_id: '123', message_id: '456', badge_count: 'not-a-number'},
|
||||
notification: {title: 'Alice', body: 'Hello'},
|
||||
});
|
||||
expect(unparseable.aps).not.toHaveProperty('badge');
|
||||
});
|
||||
|
||||
it('keeps a genuine zero badge so reading the last message clears the app icon', () => {
|
||||
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
|
||||
tag: 'channel:123:456',
|
||||
data: {channel_id: '123', message_id: '456', badge_count: 0},
|
||||
notification: {title: 'Alice', body: 'Hello'},
|
||||
});
|
||||
expect((payload.aps as Record<string, unknown>).badge).toBe(0);
|
||||
});
|
||||
|
||||
it('does not use the sender avatar as the notification media image', () => {
|
||||
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
|
||||
tag: 'channel:123:456',
|
||||
data: {channel_id: '123', message_id: '456', author_avatar_url: 'https://cdn.example/avatar.png'},
|
||||
notification: {title: 'Alice', body: 'Hello', icon: 'https://cdn.example/avatar.png'},
|
||||
});
|
||||
expect(payload.image_url).toBeUndefined();
|
||||
expect(payload.aps).not.toHaveProperty('mutable-content');
|
||||
expect(payload.author_avatar_url).toBe('https://cdn.example/avatar.png');
|
||||
});
|
||||
it('marks only permanent APNs token failures as subscription deletion signals', () => {
|
||||
expect(ApnsPushServiceTestHooks.isPermanentApnsFailure(410, 'Unregistered')).toBe(true);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {channelNeedsReindexing} from './ChannelIndexingUtils';
|
||||
import type {IMessageSearchService} from './IMessageSearchService';
|
||||
import {MessageSearchResponseMapper} from './MessageSearchResponseMapper';
|
||||
import {searchExistingMessages} from './MessageSearchResultReconciler';
|
||||
import {channelRequiresAgeVerification} from './SearchNsfwUtils';
|
||||
|
||||
const CHANNEL_INDEX_CHECK_CONCURRENCY = 32;
|
||||
const CHANNEL_INDEX_JOB_ENQUEUE_CONCURRENCY = 16;
|
||||
@@ -101,7 +102,7 @@ export class GlobalSearchService {
|
||||
this.guildService.search.collectAccessibleGuildChannels(params.userId),
|
||||
this.findDmScopeContextChannel(params.userId, params.includeChannelId),
|
||||
]);
|
||||
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} = guildAccess;
|
||||
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} = guildAccess;
|
||||
if (unindexedChannelIds.size > 0) {
|
||||
await this.queueIndexingChannels(unindexedChannelIds);
|
||||
return {indexing: true};
|
||||
@@ -133,7 +134,7 @@ export class GlobalSearchService {
|
||||
if (guildIsAgeRestricted) {
|
||||
return canIncludeNsfw;
|
||||
}
|
||||
if (channel.isNsfw) {
|
||||
if (channelRequiresAgeVerification(channel, parentCategories, false)) {
|
||||
return canIncludeNsfw;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type ChannelID, createMessageID, type GuildID, type MessageID} from '../BrandedTypes';
|
||||
import {type ChannelID, createMessageID, type MessageID} from '../BrandedTypes';
|
||||
import {Logger} from '../Logger';
|
||||
import {getMessageSearchService} from '../SearchFactory';
|
||||
import type {IMessageSearchService} from './IMessageSearchService';
|
||||
@@ -59,18 +59,3 @@ export async function deleteChannelMessageSearchDocuments(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteGuildMessageSearchDocuments(guildId: GuildID, options: CleanupOptions = {}): Promise<void> {
|
||||
const searchService = resolveMessageSearchService(options.searchService);
|
||||
if (!searchService) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await searchService.deleteGuildMessages(guildId);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{...options.context, guildId: guildId.toString(), error},
|
||||
'Failed to delete guild message documents from search index',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {channelToContentWarningView, computeEffectiveChannelNsfw} from '../channel/utils/EffectiveContentWarning';
|
||||
import type {Channel} from '../models/Channel';
|
||||
|
||||
export function channelRequiresAgeVerification(
|
||||
channel: Channel,
|
||||
channelsById: ReadonlyMap<string, Channel>,
|
||||
guildNsfw: boolean,
|
||||
): boolean {
|
||||
const parentCategory =
|
||||
channel.parentId != null ? (channelsById.get(channel.parentId.toString()) ?? null) : null;
|
||||
return computeEffectiveChannelNsfw(
|
||||
channelToContentWarningView(channel),
|
||||
parentCategory ? channelToContentWarningView(parentCategory) : null,
|
||||
{nsfw: guildNsfw, contentWarningLevel: ContentWarningLevel.INHERIT, contentWarningText: null},
|
||||
);
|
||||
}
|
||||
@@ -256,7 +256,7 @@ export class StripeRefundService {
|
||||
return;
|
||||
}
|
||||
const subscriptionId = refund.metadata.subscription_id;
|
||||
if (subscriptionId && !user.firstRefundAt) {
|
||||
if (subscriptionId) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
} catch (error) {
|
||||
|
||||
@@ -37,6 +37,7 @@ export class TestMediaService extends IMediaService {
|
||||
height: 128,
|
||||
animated: stored ? this.isAnimatedImage(stored, format) : format === 'gif',
|
||||
nsfw: false,
|
||||
placeholder: this.fakePlaceholder(request.key),
|
||||
};
|
||||
}
|
||||
if (request.type === 'upload') {
|
||||
@@ -55,6 +56,7 @@ export class TestMediaService extends IMediaService {
|
||||
height: 128,
|
||||
animated: format === 'gif',
|
||||
nsfw: false,
|
||||
placeholder: this.fakePlaceholder(request.upload_filename),
|
||||
};
|
||||
}
|
||||
if (request.type === 'external') {
|
||||
@@ -67,11 +69,16 @@ export class TestMediaService extends IMediaService {
|
||||
height: 128,
|
||||
animated: false,
|
||||
nsfw: false,
|
||||
placeholder: this.fakePlaceholder(request.url),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private fakePlaceholder(seed: string): string {
|
||||
return crypto.createHash('sha256').update(seed).digest('base64').slice(0, 24);
|
||||
}
|
||||
|
||||
getExternalMediaProxyURL(): string {
|
||||
return 'https://media-proxy.test';
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export class TokenRepository {
|
||||
}
|
||||
|
||||
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
|
||||
await upsertOne(EmailRevertTokens.insert(tokenData));
|
||||
await upsertOne(EmailRevertTokens.insertWithTtl(tokenData, seconds('24 hours')));
|
||||
return new EmailRevertToken(tokenData);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,14 +89,17 @@ export class UserAccountLookupService {
|
||||
let guildMember: GuildMemberResponse | null = null;
|
||||
let guildMemberDomain: GuildMember | null = null;
|
||||
if (guildId != null) {
|
||||
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
|
||||
if (guildMemberDomain) {
|
||||
guildMember = await this.deps.guildService.members.getMember({
|
||||
userId,
|
||||
targetId,
|
||||
guildId,
|
||||
requestCache,
|
||||
});
|
||||
const viewerMember = await this.deps.guildRepository.getMember(guildId, userId);
|
||||
if (viewerMember) {
|
||||
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
|
||||
if (guildMemberDomain) {
|
||||
guildMember = await this.deps.guildService.members.getMember({
|
||||
userId,
|
||||
targetId,
|
||||
guildId,
|
||||
requestCache,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let premiumType = user.premiumType ?? undefined;
|
||||
|
||||
@@ -118,15 +118,17 @@ export class UserRelationshipService {
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Relationship> {
|
||||
if (!staffForceAccept && (await getInstanceConfigRepository().getInstancePolicyConfig()).direct_messages_disabled) {
|
||||
const requesterUser = await this.userRepository.findUnique(userId);
|
||||
const requesterIsStaff =
|
||||
requesterUser != null && (requesterUser.flags & UserFlags.STAFF) === UserFlags.STAFF;
|
||||
if (
|
||||
!requesterIsStaff &&
|
||||
(await getInstanceConfigRepository().getInstancePolicyConfig()).direct_messages_disabled
|
||||
) {
|
||||
throw new DirectMessagesDisabledError();
|
||||
}
|
||||
const requesterUser = await this.userRepository.findUnique(userId);
|
||||
if (staffForceAccept) {
|
||||
const requesterIsStaff = requesterUser != null && (requesterUser.flags & UserFlags.STAFF) === UserFlags.STAFF;
|
||||
if (requesterIsStaff) {
|
||||
return await this.forceCreateFriendship({userId, targetId, userCacheService, requestCache});
|
||||
}
|
||||
if (staffForceAccept && requesterIsStaff) {
|
||||
return await this.forceCreateFriendship({userId, targetId, userCacheService, requestCache});
|
||||
}
|
||||
if (!requesterUser) {
|
||||
throw new UnknownUserError();
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
import {MessageAttachmentFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {AttachmentDecayRepository} from '../../attachment/AttachmentDecayRepository';
|
||||
import {createAttachmentID} from '../../BrandedTypes';
|
||||
import {createAttachmentID, createMemeID, createUserID} from '../../BrandedTypes';
|
||||
import {
|
||||
createTestAccountForAttachmentTests,
|
||||
sendMessageWithAttachments,
|
||||
setupTestGuildAndChannel,
|
||||
} from '../../channel/tests/AttachmentTestUtils';
|
||||
import {fetchOne} from '../../database/CassandraQueryExecution';
|
||||
import {Db} from '../../database/CassandraTypes';
|
||||
import {FavoriteMemes} from '../../Tables';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
@@ -42,6 +45,24 @@ interface MessageWithDecayAttachment {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MessageWithPlaceholderAttachment {
|
||||
id: string;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
filename: string;
|
||||
placeholder?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function clearFavoriteMemePlaceholder(userId: string, memeId: string) {
|
||||
await fetchOne(
|
||||
FavoriteMemes.patchByPk(
|
||||
{user_id: createUserID(BigInt(userId)), meme_id: createMemeID(BigInt(memeId))},
|
||||
{placeholder: Db.set(null)},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchDecayRow(attachmentId: string) {
|
||||
return new AttachmentDecayRepository().fetchById(createAttachmentID(BigInt(attachmentId)));
|
||||
}
|
||||
@@ -111,6 +132,43 @@ describe('Favorite Meme Operations', () => {
|
||||
expect(sent.attachments[0].filename).toBe(filename);
|
||||
expect(sent.attachments[0].flags & MessageAttachmentFlags.IS_ANIMATED).toBe(MessageAttachmentFlags.IS_ANIMATED);
|
||||
});
|
||||
test('should carry the saved placeholder onto the sent attachment', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Placeholder Meme',
|
||||
});
|
||||
expect(meme.placeholder).toBeTruthy();
|
||||
const sent = await createBuilder<MessageWithPlaceholderAttachment>(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({favorite_meme_id: meme.id})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(sent.attachments[0].placeholder).toBe(meme.placeholder);
|
||||
});
|
||||
test('should read-repair a missing placeholder when sending a favorite meme', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
|
||||
attachment_id: message.attachments[0].id,
|
||||
name: 'Repair Meme',
|
||||
});
|
||||
expect(meme.placeholder).toBeTruthy();
|
||||
await clearFavoriteMemePlaceholder(account.userId, meme.id);
|
||||
const stripped = await getFavoriteMeme(harness, account.token, meme.id);
|
||||
expect(stripped.placeholder).toBeNull();
|
||||
const sent = await createBuilder<MessageWithPlaceholderAttachment>(harness, account.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({favorite_meme_id: meme.id})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(sent.attachments[0].placeholder).toBeTruthy();
|
||||
const repaired = await getFavoriteMeme(harness, account.token, meme.id);
|
||||
expect(repaired.placeholder).toBe(sent.attachments[0].placeholder);
|
||||
});
|
||||
test('should create decay metadata when sending favorite meme', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
|
||||
@@ -142,7 +142,15 @@ export class WebhookService {
|
||||
async getGuildWebhooks({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<Array<Webhook>> {
|
||||
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_WEBHOOKS);
|
||||
return await this.repository.listByGuild(guildId);
|
||||
const webhooks = await this.repository.listByGuild(guildId);
|
||||
const visibility = await Promise.all(
|
||||
webhooks.map((webhook) =>
|
||||
webhook.channelId
|
||||
? this.canManageChannelWebhooks({userId, guildId, channelId: webhook.channelId})
|
||||
: Promise.resolve(false),
|
||||
),
|
||||
);
|
||||
return webhooks.filter((_webhook, index) => visibility[index]);
|
||||
}
|
||||
|
||||
async getChannelWebhooks({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<Array<Webhook>> {
|
||||
@@ -153,6 +161,7 @@ export class WebhookService {
|
||||
guildId: channel.guildId,
|
||||
});
|
||||
await checkPermission(Permissions.MANAGE_WEBHOOKS);
|
||||
await this.assertChannelWebhookPermission({userId, guildId: channel.guildId, channelId});
|
||||
return await this.repository.listByChannel(channelId);
|
||||
}
|
||||
|
||||
@@ -172,6 +181,7 @@ export class WebhookService {
|
||||
guildId: channel.guildId,
|
||||
});
|
||||
await checkPermission(Permissions.MANAGE_WEBHOOKS);
|
||||
await this.assertChannelWebhookPermission({userId, guildId: channel.guildId, channelId});
|
||||
const guildLimit = this.resolveWebhookLimit(guildData.features, 'max_webhooks_per_guild', MAX_WEBHOOKS_PER_GUILD);
|
||||
const guildWebhookCount = await this.repository.countByGuild(channel.guildId);
|
||||
if (guildWebhookCount >= guildLimit) {
|
||||
@@ -451,9 +461,41 @@ export class WebhookService {
|
||||
if (!webhook) throw new UnknownWebhookError();
|
||||
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId: webhook.guildId!});
|
||||
await checkPermission(Permissions.MANAGE_WEBHOOKS);
|
||||
if (webhook.guildId && webhook.channelId) {
|
||||
await this.assertChannelWebhookPermission({
|
||||
userId,
|
||||
guildId: webhook.guildId,
|
||||
channelId: webhook.channelId,
|
||||
});
|
||||
}
|
||||
return webhook;
|
||||
}
|
||||
|
||||
private async canManageChannelWebhooks({
|
||||
userId,
|
||||
guildId,
|
||||
channelId,
|
||||
}: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
channelId: ChannelID;
|
||||
}): Promise<boolean> {
|
||||
const [canView, canManage] = await Promise.all([
|
||||
this.gatewayService.checkPermission({guildId, userId, permission: Permissions.VIEW_CHANNEL, channelId}),
|
||||
this.gatewayService.checkPermission({guildId, userId, permission: Permissions.MANAGE_WEBHOOKS, channelId}),
|
||||
]);
|
||||
return canView && canManage;
|
||||
}
|
||||
|
||||
private async assertChannelWebhookPermission(params: {
|
||||
userId: UserID;
|
||||
guildId: GuildID;
|
||||
channelId: ChannelID;
|
||||
}): Promise<void> {
|
||||
const allowed = await this.canManageChannelWebhooks(params);
|
||||
if (!allowed) throw new MissingPermissionsError();
|
||||
}
|
||||
|
||||
private async getTokenAuthenticatedWebhook({webhookId, token}: WebhookTokenParams): Promise<Webhook> {
|
||||
const webhook = await this.repository.findByToken(webhookId, token);
|
||||
if (!webhook) throw new UnknownWebhookError();
|
||||
|
||||
@@ -9,28 +9,28 @@ import {getWorkerDependencies} from '../WorkerContext';
|
||||
const PayloadSchema = z.object({
|
||||
guildId: z.string(),
|
||||
userId: z.string(),
|
||||
days: z.number().min(0).max(7),
|
||||
seconds: z.number().min(0).max(604800),
|
||||
});
|
||||
const deleteUserMessagesInGuildByTime: WorkerTaskHandler = async (payload, helpers) => {
|
||||
const validated = PayloadSchema.parse(payload);
|
||||
helpers.logger.debug({payload: validated}, 'Processing deleteUserMessagesInGuildByTime task');
|
||||
const guildId = createGuildID(BigInt(validated.guildId));
|
||||
const userId = createUserID(BigInt(validated.userId));
|
||||
const {days} = validated;
|
||||
const {seconds} = validated;
|
||||
Logger.debug(
|
||||
{guildId: guildId.toString(), userId: userId.toString(), days},
|
||||
{guildId: guildId.toString(), userId: userId.toString(), seconds},
|
||||
'Starting time-based message deletion for guild ban',
|
||||
);
|
||||
try {
|
||||
const {channelService} = getWorkerDependencies();
|
||||
await channelService.messages.deletion.deleteUserMessagesInGuild({guildId, userId, days});
|
||||
await channelService.messages.deletion.deleteUserMessagesInGuild({guildId, userId, seconds});
|
||||
Logger.debug(
|
||||
{guildId: guildId.toString(), userId: userId.toString(), days},
|
||||
{guildId: guildId.toString(), userId: userId.toString(), seconds},
|
||||
'Time-based message deletion completed successfully',
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{guildId: guildId.toString(), userId: userId.toString(), days, error},
|
||||
{guildId: guildId.toString(), userId: userId.toString(), seconds, error},
|
||||
'Failed to delete user messages in guild',
|
||||
);
|
||||
throw error;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '../../SearchFactory';
|
||||
import type {IGuildMemberSearchService} from '../../search/IGuildMemberSearchService';
|
||||
import type {IMessageSearchService} from '../../search/IMessageSearchService';
|
||||
import {deleteChannelMessageSearchDocuments} from '../../search/MessageSearchIndexCleanup';
|
||||
import {getWorkerDependencies} from '../WorkerContext';
|
||||
|
||||
const INDEX_TYPES = [
|
||||
@@ -190,11 +191,16 @@ const refreshChannelMessages: IndexHandler = async (payload, helpers, kvClient,
|
||||
const {channelRepository} = getWorkerDependencies();
|
||||
const guildId = createGuildID(BigInt(payload.guild_id!));
|
||||
const searchService = requireSearchService<IMessageSearchService>(getMessageSearchService());
|
||||
await searchService.deleteGuildMessages(guildId);
|
||||
const channels = await channelRepository.listGuildChannels(guildId);
|
||||
if (channels.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
for (const channel of channels) {
|
||||
await deleteChannelMessageSearchDocuments(channel.id, {
|
||||
searchService,
|
||||
context: {source: 'bulk_reindex', guildId: guildId.toString()},
|
||||
});
|
||||
}
|
||||
const completionKey = `bulk_reindex:${payload.job_id}:remaining`;
|
||||
await kvClient.del(completionKey);
|
||||
for (const channel of channels) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
:root {
|
||||
--font-fallback-sans:
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji',
|
||||
'Fluxer Fallback', 'Fluxer Symbols', 'Fluxer Math',
|
||||
'Noto Sans', 'Nirmala UI',
|
||||
'Noto Sans Bengali', 'Noto Sans Tamil', 'Noto Sans Telugu', 'Noto Sans Kannada', 'Noto Sans Malayalam',
|
||||
'Noto Sans Gujarati', 'Noto Sans Gurmukhi', 'Noto Sans Oriya', 'Noto Sans Sinhala',
|
||||
'Noto Sans Khmer', 'Noto Sans Lao', 'Noto Sans Myanmar', 'Noto Sans Tibetan',
|
||||
'Noto Sans Armenian', 'Noto Sans Georgian', 'Noto Sans Ethiopic', 'Noto Sans Cherokee',
|
||||
'Noto Sans Canadian Aboriginal', 'Noto Sans Mongolian', 'Noto Sans Vai', 'Noto Sans NKo',
|
||||
'Noto Sans Adlam', 'Noto Sans Yi', 'Noto Sans Osage', 'Noto Sans Thaana',
|
||||
'Noto Sans CJK SC', 'Noto Sans CJK TC', 'Noto Sans CJK JP', 'Noto Sans CJK KR',
|
||||
'Segoe UI Symbol', 'Segoe UI Historic', 'Apple Symbols',
|
||||
'Noto Sans Symbols', 'Noto Sans Symbols 2', 'Noto Sans Math', 'Cambria Math';
|
||||
--font-fallback-mono:
|
||||
'Noto Sans Mono',
|
||||
'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR',
|
||||
var(--font-fallback-sans);
|
||||
|
||||
--font-sans:
|
||||
'Fluxer Sans', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari', 'Fluxer Sans Thai Looped',
|
||||
'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'Segoe UI', 'Roboto', var(--font-fallback-sans), sans-serif;
|
||||
--font-mono: 'Fluxer Mono', 'Menlo', 'Monaco', 'Courier New', var(--font-fallback-mono), monospace;
|
||||
}
|
||||
|
||||
:root:lang(ja) {
|
||||
--font-sans:
|
||||
'Fluxer Sans', 'Fluxer Sans JP', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari',
|
||||
'Fluxer Sans Thai Looped', 'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans KR', system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Meiryo',
|
||||
var(--font-fallback-sans), sans-serif;
|
||||
}
|
||||
:root:lang(ko) {
|
||||
--font-sans:
|
||||
'Fluxer Sans', 'Fluxer Sans KR', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari',
|
||||
'Fluxer Sans Thai Looped', 'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'Malgun Gothic', 'Apple SD Gothic Neo', var(--font-fallback-sans), sans-serif;
|
||||
}
|
||||
:root:lang(zh-CN),
|
||||
:root:lang(zh-Hans) {
|
||||
--font-sans:
|
||||
'Fluxer Sans', 'Fluxer Sans SC', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari',
|
||||
'Fluxer Sans Thai Looped', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', var(--font-fallback-sans), sans-serif;
|
||||
}
|
||||
:root:lang(zh-Hant-TW),
|
||||
:root:lang(zh-TW) {
|
||||
--font-sans:
|
||||
'Fluxer Sans', 'Fluxer Sans TC', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari',
|
||||
'Fluxer Sans Thai Looped', 'Fluxer Sans SC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'PingFang TC', 'Microsoft JhengHei', var(--font-fallback-sans), sans-serif;
|
||||
}
|
||||
:root:lang(zh-HK),
|
||||
:root:lang(zh-Hant-HK) {
|
||||
--font-sans:
|
||||
'Fluxer Sans', 'Fluxer Sans TC', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari',
|
||||
'Fluxer Sans Thai Looped', 'Fluxer Sans SC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'PingFang HK', 'Microsoft JhengHei', var(--font-fallback-sans), sans-serif;
|
||||
}
|
||||
:root:lang(ar) {
|
||||
--font-sans:
|
||||
'Fluxer Sans', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari', 'Fluxer Sans Thai Looped',
|
||||
'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'Segoe UI', 'Geeza Pro', 'Arabic Typesetting', var(--font-fallback-sans), sans-serif;
|
||||
}
|
||||
:root:lang(he) {
|
||||
--font-sans:
|
||||
'Fluxer Sans', 'Fluxer Sans Hebrew', 'Fluxer Sans Arabic', 'Fluxer Sans Devanagari', 'Fluxer Sans Thai Looped',
|
||||
'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'Segoe UI', 'Arial Hebrew', var(--font-fallback-sans), sans-serif;
|
||||
}
|
||||
:root:lang(hi),
|
||||
:root:lang(mr),
|
||||
:root:lang(ne),
|
||||
:root:lang(sa) {
|
||||
--font-sans:
|
||||
'Fluxer Sans', 'Fluxer Sans Devanagari', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Thai Looped',
|
||||
'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'Nirmala UI', 'Noto Sans Devanagari', var(--font-fallback-sans), sans-serif;
|
||||
}
|
||||
:root:lang(th) {
|
||||
--font-sans:
|
||||
'Fluxer Sans', 'Fluxer Sans Thai Looped', 'Fluxer Sans Arabic', 'Fluxer Sans Hebrew', 'Fluxer Sans Devanagari',
|
||||
'Fluxer Sans SC', 'Fluxer Sans TC', 'Fluxer Sans JP', 'Fluxer Sans KR', system-ui, -apple-system,
|
||||
BlinkMacSystemFont, 'Leelawadee UI', 'Thonburi', var(--font-fallback-sans), sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
Copyright 2012-2025 The Noto Project Authors (https://github.com/notofonts)
|
||||
|
||||
This Font Software is a subset of Google Noto Sans faces, self-hosted for
|
||||
guaranteed script coverage. The unmodified license follows.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the fonts.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user