Очень сильно переделать формат блоков

Внимание: версия ещё не проверена.
This commit is contained in:
AidarKC
2026-10-01 09:34:19 +03:00
parent 08c7fd7676
commit 3281da7f5c
65 changed files with 1863 additions and 2743 deletions
@@ -37,7 +37,7 @@ public final class BodyRecordParser {
int key = (t << 16) | v;
BodyRecord r = switch (key) {
case TextBody.KEY -> {
case TextLineBody.KEY -> {
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
@@ -1,32 +1,25 @@
package blockchain.body;
/**
* BodyHasTarget — дополнительный интерфейс для body, которые ссылаются на логическую цель.
* Body that refers to a logical block target.
*
* Актуальное правило target:
* - в подписываемых байтах хранится login цели;
* - номер fork/blockchainName в target не хранится;
* - идентичность цели задаётся как login + blockNumber + blockHash.
*
* Это позволяет одной и той же логической записи сохранять ссылки после fork,
* если её номер и SHA-256 hash остались прежними.
* Canonical rules:
* - external target: login + forkNumber + blockNumber + blockHash;
* - own EDIT target: blockNumber + blockHash only;
* - logical identity NEVER includes forkNumber: login + blockNumber + blockHash;
* - forkNumber is informational/historical only (0 = unknown/reserved).
*/
public interface BodyHasTarget {
/** login цели. Актуальные runtime-body обязаны переопределять этот метод. */
/** Target login; null for own-chain compact EDIT targets. */
default String toLogin() { return null; }
/**
* Legacy source-level accessor. В новом подписанном target blockchainName не хранится.
* Оставлен временно только чтобы старые вспомогательные классы компилировались;
* runtime-логика не должна использовать его для новых блоков.
*/
/** Historical fork number recorded by the author; null for compact EDIT targets. */
default Integer toForkNumber() { return null; }
/** Runtime-only physical blockchain cache; never serialized in canonical targets. */
@Deprecated
default String toBchName() { return null; }
/** globalNumber цели (nullable только если конкретный subtype не содержит target). */
Integer toBlockGlobalNumber();
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
byte[] toBlockHashBytes();
}
@@ -0,0 +1,114 @@
package blockchain.body;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/** Shared codec/validation for canonical SHiNE target formats. */
final class BodyTargetSupport {
private BodyTargetSupport() {}
record ExternalTarget(String login, int forkNumber, int blockNumber, byte[] blockHash32) {}
static ExternalTarget readExternal(ByteBuffer bb, String ctx, int bytesRequiredAfterTarget) {
ensureMin(bb, 1 + 1 + 2 + 4 + 32 + bytesRequiredAfterTarget, ctx + " target too short");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException(ctx + " toLoginLen is 0");
ensureMin(bb, loginLen + 2 + 4 + 32 + bytesRequiredAfterTarget, ctx + " target payload too short");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
String login = decodeStrictUtf8(loginBytes, ctx + " toLogin");
if (login.isBlank()) throw new IllegalArgumentException(ctx + " toLogin is blank");
int forkNumber = Short.toUnsignedInt(bb.getShort());
int blockNumber = bb.getInt();
if (blockNumber < 0) throw new IllegalArgumentException(ctx + " toBlockGlobalNumber < 0");
byte[] hash = new byte[32];
bb.get(hash);
requireNonZeroHash32(hash, ctx + " toBlockHash32");
return new ExternalTarget(login, forkNumber, blockNumber, hash);
}
static int externalSize(String login) {
return 1 + loginBytes(login).length + 2 + 4 + 32;
}
static void writeExternal(ByteBuffer bb, String login, int forkNumber, int blockNumber, byte[] hash32, String ctx) {
byte[] loginBytes = loginBytes(login);
validateForkNumber(forkNumber, ctx);
if (blockNumber < 0) throw new IllegalArgumentException(ctx + " toBlockGlobalNumber < 0");
requireNonZeroHash32(hash32, ctx + " toBlockHash32");
bb.put((byte) loginBytes.length);
bb.put(loginBytes);
bb.putShort((short) forkNumber);
bb.putInt(blockNumber);
bb.put(hash32);
}
static void validateExternal(String login, Integer forkNumber, Integer blockNumber, byte[] hash32, String ctx) {
loginBytes(login);
if (forkNumber == null) throw new IllegalArgumentException(ctx + " toForkNumber is null");
validateForkNumber(forkNumber, ctx);
if (blockNumber == null || blockNumber < 0) throw new IllegalArgumentException(ctx + " toBlockGlobalNumber invalid");
requireNonZeroHash32(hash32, ctx + " toBlockHash32");
}
static void validateCompact(Integer blockNumber, byte[] hash32, String ctx) {
if (blockNumber == null || blockNumber < 0) throw new IllegalArgumentException(ctx + " toBlockGlobalNumber invalid");
requireNonZeroHash32(hash32, ctx + " toBlockHash32");
}
static byte[] readCompactHash(ByteBuffer bb, String ctx) {
ensureMin(bb, 4 + 32, ctx + " target too short");
int blockNumber = bb.getInt();
if (blockNumber < 0) throw new IllegalArgumentException(ctx + " toBlockGlobalNumber < 0");
byte[] hash = new byte[32];
bb.get(hash);
requireNonZeroHash32(hash, ctx + " toBlockHash32");
return hash;
}
static byte[] loginBytes(String login) {
if (login == null || login.isBlank()) throw new IllegalArgumentException("toLogin is blank");
byte[] bytes = login.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0 || bytes.length > 255) throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
return bytes;
}
static void validateForkNumber(int forkNumber, String ctx) {
if (forkNumber < 0 || forkNumber > 999) {
throw new IllegalArgumentException(ctx + " toForkNumber must be 0..999");
}
}
static void requireNonZeroHash32(byte[] hash32, String field) {
if (hash32 == null || hash32.length != 32) throw new IllegalArgumentException(field + " must be 32 bytes");
boolean any = false;
for (byte b : hash32) any |= b != 0;
if (!any) throw new IllegalArgumentException(field + " must not be zero hash");
}
static byte[] copyHash(byte[] hash32) {
return hash32 == null ? null : Arrays.copyOf(hash32, 32);
}
static String decodeStrictUtf8(byte[] bytes, String field) {
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(field + " is not valid UTF-8", e);
}
}
static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
}
@@ -4,186 +4,88 @@ import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* ConnectionBody — type=3, ver=1 (в заголовке блока).
*
* bodyBytes (BigEndian):
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes)
*
* Номер fork/blockchainName в target не хранится.
*/
/** CONNECTION with line fields and canonical external target. */
public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
public static final short TYPE = 3;
public static final short VER = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType;
public final short version;
public final int lineCode;
public final int prevLineNumber;
public final byte[] prevLineHash32;
public final int thisLineNumber;
public final String toLogin;
public final int toForkNumber;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ConnectionBody version must be 1, got=" + (this.version & 0xFFFF));
}
if (!isValidSubType(this.subType)) {
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
}
if (bodyBytes.length < 4 + 4 + 32 + 4 + 1 + 1 + 4 + 32) {
throw new IllegalArgumentException("ConnectionBody too short");
}
this.subType = subType; this.version = version;
if ((version & 0xFFFF) != 1 || !isValidSubType(subType)) throw new IllegalArgumentException("Bad Connection header");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
BodyTargetSupport.ensureMin(bb, 4 + 4 + 32 + 4 + 1 + 1 + 2 + 4 + 32, "ConnectionBody too short");
this.lineCode = bb.getInt(); this.prevLineNumber = bb.getInt(); this.prevLineHash32 = new byte[32]; bb.get(this.prevLineHash32); this.thisLineNumber = bb.getInt();
BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "CONNECTION", 0);
this.toLogin = t.login(); this.toForkNumber = t.forkNumber(); this.toBlockGlobalNumber = t.blockNumber(); this.toBlockHash32 = t.blockHash32();
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected ConnectionBody tail");
}
public ConnectionBody(int lineCode,
int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
short subType,
String toLogin,
int toBlockGlobalNumber,
byte[] toBlockHash32) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber;
public ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, short subType,
String toLogin, int toForkNumber, int toBlockGlobalNumber, byte[] toBlockHash32) {
if (lineCode < 0 || !isValidSubType(subType)) throw new IllegalArgumentException("Bad connection fields");
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION");
this.lineCode = lineCode; this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32);
this.thisLineNumber = thisLineNumber;
this.subType = subType;
this.version = VER;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.thisLineNumber = thisLineNumber; this.subType = subType; this.version = VER;
this.toLogin = toLogin; this.toForkNumber = toForkNumber; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
}
@Override
public ConnectionBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
@Override public ConnectionBody check() {
if (lineCode < 0 || !isValidSubType(subType)) throw new IllegalArgumentException("Bad connection fields");
if (prevLineNumber == -1) {
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
} else if (prevLineHash32 == null || prevLineHash32.length != 32) {
throw new IllegalArgumentException("prevLineHash32 invalid");
}
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
if (!isAllZero32(prevLineHash32) || thisLineNumber != -1) throw new IllegalArgumentException("Bad empty connection line anchor");
} else if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION");
return this;
}
@Override
public byte[] toBytes() {
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginBytes.length == 0 || loginBytes.length > 255)
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 != 32");
int cap = 4 + 4 + 32 + 4 + 1 + loginBytes.length + 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.put((byte) loginBytes.length);
bb.put(loginBytes);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
@Override public byte[] toBytes() {
check();
ByteBuffer bb = ByteBuffer.allocate(4 + 4 + 32 + 4 + BodyTargetSupport.externalSize(toLogin)).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode).putInt(prevLineNumber).put(prevLineHash32).putInt(thisLineNumber);
BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "CONNECTION");
return bb.array();
}
private static boolean isValidSubType(short st) {
int v = st & 0xFFFF;
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SPOUSE & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNSPOUSE & 0xFFFF)
|| v == (MsgSubType.CONNECTION_PARENT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNPARENT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_CHILD & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SIBLING & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF)
|| v == (MsgSubType.CONNECTION_KNOWN_PERSON & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNKNOWN_PERSON & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF)
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
}
private static boolean isAllZero32(byte[] b) {
if (b == null || b.length != 32) return true;
for (byte value : b) if (value != 0) return false;
return true;
}
@Override public int lineCode() { return lineCode; }
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
@Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); }
@Override public int lineSeq() { return thisLineNumber; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toForkNumber() { return toForkNumber; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@Override public byte[] toBlockHashBytes() { return Arrays.copyOf(toBlockHash32, 32); }
private static boolean isValidSubType(short st) {
int v = st & 0xFFFF;
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF) || v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF) || v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SPOUSE & 0xFFFF) || v == (MsgSubType.CONNECTION_UNSPOUSE & 0xFFFF)
|| v == (MsgSubType.CONNECTION_PARENT & 0xFFFF) || v == (MsgSubType.CONNECTION_UNPARENT & 0xFFFF)
|| v == (MsgSubType.CONNECTION_CHILD & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SIBLING & 0xFFFF) || v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF)
|| v == (MsgSubType.CONNECTION_KNOWN_PERSON & 0xFFFF) || v == (MsgSubType.CONNECTION_UNKNOWN_PERSON & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF) || v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF) || v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF)
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF) || v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
}
private static boolean isAllZero32(byte[] b) { if (b == null || b.length != 32) return false; for (byte v : b) if (v != 0) return false; return true; }
}
@@ -5,123 +5,107 @@ import utils.config.ShineSignatureConstants;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* HeaderBody — type=0, version=1.
*
* В новом формате type/subType/version живут в HEADER блока,
* поэтому bodyBytes для HeaderBody содержат только payload:
* HeaderBody — type=0, subType=0, version=1.
*
* bodyBytes (BigEndian):
* [TAG_LEN] tag ASCII "SHiNE"
* [1] loginLength=N (uint8)
* [N] login UTF-8
* [TAG_LEN] tag ASCII
* [1] loginLength (uint8)
* [N] login UTF-8
* [32] initialBlockchainKey32
*
* initialBlockchainKey32 is the public blockchain-signing key that created fork #1.
* It is historical identity metadata and MUST remain byte-for-byte unchanged when a
* preserved prefix (including block #0) is republished into later forks.
*/
public final class HeaderBody implements BodyRecord {
public static final short TYPE = 0;
public static final short VER = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
/** Для header subType всегда 0 (служебная совместимость). */
public static final short SUBTYPE_COMPAT = 0;
/** TAG формата (ASCII). */
public static final String TAG = ShineSignatureConstants.BLOCKCHAIN_HEADER_TAG;
private static final byte[] TAG_ASCII = TAG.getBytes(StandardCharsets.US_ASCII);
private static final int TAG_LEN = TAG_ASCII.length;
public final short subType; // всегда 0 (из заголовка блока)
public final short version; // из заголовка блока
public final String tag; // "SHiNE"
public final short subType;
public final short version;
public final String tag;
public final String login;
public final byte[] initialBlockchainKey32;
/** Десериализация из payload bodyBytes (без type/subType/version). */
public HeaderBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF)) {
throw new IllegalArgumentException("HeaderBody subType must be 0, got=" + (this.subType & 0xFFFF));
}
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("HeaderBody version must be 1, got=" + (this.version & 0xFFFF));
}
// минимум: tag[TAG_LEN] + loginLen[1]
if (bodyBytes.length < TAG_LEN + 1) throw new IllegalArgumentException("HeaderBody too short");
if ((subType & 0xFFFF) != 0) throw new IllegalArgumentException("HeaderBody subType must be 0");
if ((version & 0xFFFF) != (VER & 0xFFFF)) throw new IllegalArgumentException("HeaderBody version must be 1");
if (bodyBytes.length < TAG_LEN + 1 + 1 + 32) throw new IllegalArgumentException("HeaderBody too short");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
byte[] tagBytes = new byte[TAG_LEN];
bb.get(tagBytes);
String t = new String(tagBytes, StandardCharsets.US_ASCII);
if (!TAG.equals(t)) throw new IllegalArgumentException("Bad tag: " + t);
this.tag = t;
String parsedTag = new String(tagBytes, StandardCharsets.US_ASCII);
if (!TAG.equals(parsedTag)) throw new IllegalArgumentException("Bad tag: " + parsedTag);
this.tag = parsedTag;
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0 || bb.remaining() < loginLen)
throw new IllegalArgumentException("Bad login length");
if (loginLen <= 0 || bb.remaining() != loginLen + 32) throw new IllegalArgumentException("Bad HeaderBody login/key length");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.login = new String(loginBytes, StandardCharsets.UTF_8);
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
this.login = BodyTargetSupport.decodeStrictUtf8(loginBytes, "HeaderBody login");
this.initialBlockchainKey32 = new byte[32];
bb.get(this.initialBlockchainKey32);
requireNonZeroKey(this.initialBlockchainKey32);
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected HeaderBody tail");
}
/** Создание “вручную”. */
public HeaderBody(String login) {
public HeaderBody(String login, byte[] initialBlockchainKey32) {
Objects.requireNonNull(login, "login == null");
Objects.requireNonNull(initialBlockchainKey32, "initialBlockchainKey32 == null");
this.subType = SUBTYPE_COMPAT;
this.version = VER;
this.tag = TAG;
this.login = login;
this.initialBlockchainKey32 = Arrays.copyOf(initialBlockchainKey32, initialBlockchainKey32.length);
check();
}
@Override
public HeaderBody check() {
if ((subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF))
throw new IllegalArgumentException("HeaderBody subType must be 0");
if (login == null || login.isBlank())
throw new IllegalArgumentException("Login is blank");
if (!login.matches("^[A-Za-z0-9_]+$"))
throw new IllegalArgumentException("Login must match ^[A-Za-z0-9_]+$");
if ((subType & 0xFFFF) != 0) throw new IllegalArgumentException("HeaderBody subType must be 0");
if (login == null || login.isBlank()) throw new IllegalArgumentException("Login is blank");
if (!login.matches("^[A-Za-z0-9_]+$")) throw new IllegalArgumentException("Login must match ^[A-Za-z0-9_]+$");
byte[] loginUtf8 = login.getBytes(StandardCharsets.UTF_8);
if (loginUtf8.length == 0 || loginUtf8.length > 255) throw new IllegalArgumentException("Login utf8 len must be 1..255");
requireNonZeroKey(initialBlockchainKey32);
return this;
}
@Override
public byte[] toBytes() {
check();
byte[] loginUtf8 = login.getBytes(StandardCharsets.UTF_8);
if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("Login utf8 len must be 1..255");
int cap = TAG_LEN + 1 + loginUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
ByteBuffer bb = ByteBuffer.allocate(TAG_LEN + 1 + loginUtf8.length + 32).order(ByteOrder.BIG_ENDIAN);
bb.put(TAG_ASCII);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.put(initialBlockchainKey32);
return bb.array();
}
private static void requireNonZeroKey(byte[] key) {
if (key == null || key.length != 32) throw new IllegalArgumentException("initialBlockchainKey32 must be 32 bytes");
boolean any = false;
for (byte b : key) any |= b != 0;
if (!any) throw new IllegalArgumentException("initialBlockchainKey32 must not be zero");
}
@Override
public String toString() {
return """
HeaderBody {
тип записи : HEADER (type=0, ver=1) [в заголовке блока]
subType : 0 (compat)
тег формата : "%s"
login владельца : "%s"
}
""".formatted(tag, login);
return "HeaderBody{login='" + login + "', initialBlockchainKey32=<32 bytes>}";
}
}
}
@@ -4,121 +4,68 @@ import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* ReactionBody — type=2, version=1 (в заголовке блока).
*
* subType (в заголовке блока):
* 1 = LIKE
* 2 = UNLIKE
*
* bodyBytes (BigEndian):
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes)
*
* Номер fork/blockchainName в target не хранится.
* ЛИНИИ НЕТ.
*/
/** REACTION LIKE/UNLIKE. External target = login + forkNumber + blockNumber + hash32. */
public final class ReactionBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 2;
public static final short VER = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType;
public final short version;
public final String toLogin;
public final int toForkNumber;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public ReactionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ReactionBody version must be 1, got=" + (this.version & 0xFFFF));
}
if (!isSupportedSubType(this.subType)) {
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
}
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
if ((version & 0xFFFF) != (VER & 0xFFFF)) throw new IllegalArgumentException("ReactionBody version must be 1");
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "REACTION", 0);
this.toLogin = t.login();
this.toForkNumber = t.forkNumber();
this.toBlockGlobalNumber = t.blockNumber();
this.toBlockHash32 = t.blockHash32();
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected REACTION tail");
}
public ReactionBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
this.subType = MsgSubType.REACTION_LIKE;
public ReactionBody(short subType, String toLogin, int toForkNumber, int toBlockGlobalNumber, byte[] toBlockHash32) {
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad reaction subType");
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REACTION");
this.subType = subType;
this.version = VER;
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toLogin = toLogin;
this.toForkNumber = toForkNumber;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
}
@Override
public ReactionBody check() {
if (!isSupportedSubType(subType))
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0)
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 invalid");
@Override public ReactionBody check() {
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad reaction subType");
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REACTION");
return this;
}
@Override
public byte[] toBytes() {
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginBytes.length == 0 || loginBytes.length > 255)
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
ByteBuffer bb = ByteBuffer.allocate(1 + loginBytes.length + 4 + 32).order(ByteOrder.BIG_ENDIAN);
bb.put((byte) loginBytes.length);
bb.put(loginBytes);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
@Override public byte[] toBytes() {
check();
ByteBuffer bb = ByteBuffer.allocate(BodyTargetSupport.externalSize(toLogin)).order(ByteOrder.BIG_ENDIAN);
BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REACTION");
return bb.array();
}
@Override public String toLogin() { return toLogin; }
@Override public Integer toForkNumber() { return toForkNumber; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@Override public byte[] toBlockHashBytes() { return Arrays.copyOf(toBlockHash32, 32); }
private static boolean isSupportedSubType(short subType) {
int st = subType & 0xFFFF;
return st == (MsgSubType.REACTION_LIKE & 0xFFFF)
|| st == (MsgSubType.REACTION_UNLIKE & 0xFFFF);
return st == (MsgSubType.REACTION_LIKE & 0xFFFF) || st == (MsgSubType.REACTION_UNLIKE & 0xFFFF);
}
}
@@ -10,21 +10,8 @@ import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* StatusActionBody — type=5, ver=1.
*
* Все STATUS_ACTION имеют target на конкретный логический блок и опциональный текст-пояснение.
*
* Формат bodyBytes (BigEndian):
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*/
/** STATUS_ACTION. External target = login + forkNumber + blockNumber + hash32. */
public final class StatusActionBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 5;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
@@ -32,124 +19,69 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
public final short subType;
public final short version;
public final String toLogin;
public final int toForkNumber;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
public StatusActionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("StatusActionBody version must be 1, got=" + (version & 0xFFFF));
}
if (!isSupportedSubType(subType)) {
throw new IllegalArgumentException("Unsupported STATUS_ACTION subType: " + (subType & 0xFFFF));
}
this.subType = subType; this.version = version;
if ((version & 0xFFFF) != 1 || !isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION header");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toLoginLen is 0");
ensureMin(bb, loginLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
ensureNoTail(bb, "StatusActionBody");
BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "STATUS_ACTION", 2);
this.toLogin = t.login(); this.toForkNumber = t.forkNumber(); this.toBlockGlobalNumber = t.blockNumber(); this.toBlockHash32 = t.blockHash32();
this.message = readText(bb);
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected STATUS_ACTION tail");
}
public StatusActionBody(short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
public StatusActionBody(short subType, String toLogin, int toForkNumber, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(message, "message == null");
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.subType = subType;
this.version = VER;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "STATUS_ACTION");
this.subType = subType; this.version = VER; this.toLogin = toLogin; this.toForkNumber = toForkNumber;
this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); this.message = message;
}
@Override
public StatusActionBody check() {
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("STATUS_ACTION toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
@Override public StatusActionBody check() {
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType");
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "STATUS_ACTION");
if (message == null) throw new IllegalArgumentException("message is null");
if (message.getBytes(StandardCharsets.UTF_8).length > 65535) throw new IllegalArgumentException("Text too long");
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("STATUS_ACTION toLogin utf8 len must be 1..255");
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
@Override public byte[] toBytes() {
check();
byte[] text = message.getBytes(StandardCharsets.UTF_8);
ByteBuffer bb = ByteBuffer.allocate(BodyTargetSupport.externalSize(toLogin) + 2 + text.length).order(ByteOrder.BIG_ENDIAN);
BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "STATUS_ACTION");
bb.putShort((short) text.length).put(text);
return bb.array();
}
@Override public String toLogin() { return toLogin; }
@Override public Integer toForkNumber() { return toForkNumber; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@Override public byte[] toBlockHashBytes() { return Arrays.copyOf(toBlockHash32, 32); }
private static boolean isSupportedSubType(short subType) {
int st = subType & 0xFFFF;
return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF)
|| st == (MsgSubType.STATUS_LEARNED & 0xFFFF)
|| st == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)
|| st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)
|| st == (MsgSubType.STATUS_INTERESTED & 0xFFFF)
|| st == (MsgSubType.STATUS_STARTED & 0xFFFF)
|| st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|| st == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF) || st == (MsgSubType.STATUS_LEARNED & 0xFFFF)
|| st == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF) || st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)
|| st == (MsgSubType.STATUS_INTERESTED & 0xFFFF) || st == (MsgSubType.STATUS_STARTED & 0xFFFF)
|| st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF) || st == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|| st == (MsgSubType.STATUS_COMPLETED & 0xFFFF);
}
private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) {
private static String readText(ByteBuffer bb) {
if (bb.remaining() < 2) throw new IllegalArgumentException("STATUS_ACTION text length missing");
int len = Short.toUnsignedInt(bb.getShort());
if (bb.remaining() < len) throw new IllegalArgumentException("STATUS_ACTION text payload too short");
if (len == 0) return "";
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
byte[] bytes = new byte[len]; bb.get(bytes);
var dec = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
try { return dec.decode(ByteBuffer.wrap(bytes)).toString(); }
catch (CharacterCodingException e) { throw new IllegalArgumentException("STATUS_ACTION text invalid UTF-8", e); }
}
}
@@ -1,544 +0,0 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* TextBody — type=1, ver=1 (в заголовке блока).
*
* subType (в заголовке блока):
* 10 = POST
* 11 = EDIT_POST
* 20 = REPLY
* 21 = EDIT_REPLY
*
* =========================================================================
* КОНЦЕПЦИЯ ЛИНИЙ ДЛЯ ТЕКСТОВЫХ СООБЩЕНИЙ:
*
* POST и EDIT_POST принадлежат ЛИНИИ КАНАЛА и имеют hasLine.
* В новом формате добавлен lineCode:
* lineCode = 0 для канала "0"
* lineCode = blockNumber "заглавия линии/канала" (например CREATE_CHANNEL)
*
* REPLY и EDIT_REPLY НЕ имеют линии (нет hasLine в байтах).
*
* =========================================================================
* ФОРМАТЫ bodyBytes (BigEndian):
*
* 1) POST (subType=10):
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [2] textLenBytes (uint16)
* [N] text UTF-8
*
* 2) EDIT_POST (subType=11):
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
*
* hasTarget (на ОРИГИНАЛЬНЫЙ POST, toBchName НЕ хранить):
* [4] toBlockGlobalNumber
* [32] toBlockHash32
*
* [2] textLenBytes (uint16)
* [N] text UTF-8
*
* 3) REPLY (subType=20) — НЕ в линии:
* hasTarget:
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
*
* [2] textLenBytes (uint16)
* [M] text UTF-8
*
* 4) EDIT_REPLY (subType=21) — НЕ в линии:
* hasTarget (на ОРИГИНАЛЬНЫЙ REPLY, toBchName НЕ хранить):
* [4] toBlockGlobalNumber
* [32] toBlockHash32
*
* [2] textLenBytes (uint16)
* [N] text UTF-8
*/
public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine {
public static final short TYPE = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header
public final short version; // из header
// ===== line fields (только для POST/EDIT_POST) =====
// Для REPLY/EDIT_REPLY эти поля НЕ сериализуются; значения держим как "пустые".
public final int lineCode; // только для line-message; иначе -1
public final int prevLineNumber;
public final byte[] prevLineHash32; // 32 or null
public final int thisLineNumber;
// ===== message text =====
public final String message;
// ===== target fields =====
// REPLY: toBlockchainName + globalNumber + hash32
// EDIT_POST / EDIT_REPLY: только globalNumber + hash32 (без toBlockchainName)
public final String toBlockchainName; // nullable
public final Integer toBlockGlobalNumber; // nullable
public final byte[] toBlockHash32; // nullable (но если target есть -> 32)
/* ===================================================================== */
/* ====================== Конструктор из байт ========================== */
/* ===================================================================== */
public TextBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextBody version must be 1, got=" + (this.version & 0xFFFF));
}
if (!isValidSubType(this.subType)) {
throw new IllegalArgumentException("Bad Text subType: " + (this.subType & 0xFFFF));
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
int st = this.subType & 0xFFFF;
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
// POST: hasLine(lineCode+line) + text
ensureMin(bb, (4 + 4 + 32 + 4) + 2, "POST too short");
this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
this.message = readStrictUtf8Len16(bb, "POST text");
this.toBlockchainName = null;
this.toBlockGlobalNumber = null;
this.toBlockHash32 = null;
ensureNoTail(bb, "POST");
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// EDIT_POST: hasLine(lineCode+line) + target(no bch) + text
ensureMin(bb, (4 + 4 + 32 + 4) + (4 + 32) + 2, "EDIT_POST too short");
this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
int tgtNum = bb.getInt();
byte[] tgtHash = new byte[32];
bb.get(tgtHash);
this.toBlockchainName = null;
this.toBlockGlobalNumber = tgtNum;
this.toBlockHash32 = tgtHash;
this.message = readStrictUtf8Len16(bb, "EDIT_POST text");
ensureNoTail(bb, "EDIT_POST");
} else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
// REPLY: target(with bch) + text (без line)
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16(bb, "REPLY text");
// line fields отсутствуют в байтах
this.lineCode = -1;
this.prevLineNumber = -1;
this.prevLineHash32 = null;
this.thisLineNumber = -1;
ensureNoTail(bb, "REPLY");
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
// EDIT_REPLY: target(no bch) + text (без line)
ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");
int tgtNum = bb.getInt();
byte[] tgtHash = new byte[32];
bb.get(tgtHash);
this.toBlockchainName = null;
this.toBlockGlobalNumber = tgtNum;
this.toBlockHash32 = tgtHash;
this.message = readStrictUtf8Len16(bb, "EDIT_REPLY text");
// line fields отсутствуют в байтах
this.lineCode = -1;
this.prevLineNumber = -1;
this.prevLineHash32 = null;
this.thisLineNumber = -1;
ensureNoTail(bb, "EDIT_REPLY");
} else {
throw new IllegalArgumentException("Unsupported Text subType: " + st);
}
}
/* ===================================================================== */
/* ====================== Фабрики (удобно) ============================= */
/* ===================================================================== */
public static TextBody newPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, String message) {
return new TextBody(MsgSubType.TEXT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber,
message, null, null, null);
}
public static TextBody newEditPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber,
int targetBlockNumber, byte[] targetHash32,
String message) {
return new TextBody(MsgSubType.TEXT_EDIT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber,
message, null, targetBlockNumber, targetHash32);
}
public static TextBody newReply(String toBlockchainName, int targetBlockNumber, byte[] targetHash32, String message) {
return new TextBody(MsgSubType.TEXT_REPLY, -1, -1, null, -1,
message, toBlockchainName, targetBlockNumber, targetHash32);
}
public static TextBody newEditReply(int targetBlockNumber, byte[] targetHash32, String message) {
return new TextBody(MsgSubType.TEXT_EDIT_REPLY, -1, -1, null, -1,
message, null, targetBlockNumber, targetHash32);
}
/**
* Универсальный конструктор “вручную”.
* Для REPLY/EDIT_REPLY line поля игнорируются при сериализации (их в формате нет).
*/
public TextBody(short subType,
int lineCode,
int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
String message,
String toBlockchainName,
Integer toBlockGlobalNumber,
byte[] toBlockHash32) {
Objects.requireNonNull(message, "message == null");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF));
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
this.subType = subType;
this.version = VER;
int st = subType & 0xFFFF;
// line применима только к POST/EDIT_POST
if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message");
this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
this.thisLineNumber = thisLineNumber;
} else {
this.lineCode = -1;
this.prevLineNumber = -1;
this.prevLineHash32 = null;
this.thisLineNumber = -1;
}
this.message = message;
// target правила
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
this.toBlockchainName = null;
this.toBlockGlobalNumber = null;
this.toBlockHash32 = null;
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = null; // по ТЗ: не хранить
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = toBlockchainName;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = null; // по ТЗ: не хранить
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else {
this.toBlockchainName = null;
this.toBlockGlobalNumber = null;
this.toBlockHash32 = null;
}
}
private static boolean isValidSubType(short st) {
int v = st & 0xFFFF;
return v == (MsgSubType.TEXT_POST & 0xFFFF)
|| v == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|| v == (MsgSubType.TEXT_REPLY & 0xFFFF)
|| v == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
}
@Override
public TextBody check() {
if (!isValidSubType(subType))
throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF));
if (message == null || message.isBlank())
throw new IllegalArgumentException("Text message is blank");
int st = subType & 0xFFFF;
// локальные проверки line (БД не трогаем)
if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message");
if (prevLineHash32 == null || prevLineHash32.length != 32)
throw new IllegalArgumentException("prevLineHash32 invalid");
} else {
// reply/edit_reply: line отсутствует
if (prevLineHash32 != null)
throw new IllegalArgumentException("REPLY/EDIT_REPLY must not contain line hash");
}
// target rules
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
throw new IllegalArgumentException("POST must not contain target fields");
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
if (toBlockchainName != null)
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName in target");
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
} else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
throw new IllegalArgumentException("REPLY toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("REPLY toBlockHash32 invalid");
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
if (toBlockchainName != null)
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName in target");
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
throw new IllegalArgumentException("EDIT_REPLY toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 invalid");
}
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
// hasLine(lineCode+line) + text
int cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// hasLine(lineCode+line) + target(no bch) + text
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
int cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
} else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
// target(with bch) + text
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPLY missing toBlockGlobalNumber");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPLY toBlockHash32 != 32");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255)
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
int cap = 1 + nameUtf8.length + 4 + 32
+ 2 + msgUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
// target(no bch) + text
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_REPLY missing toBlockGlobalNumber");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 != 32");
int cap = (4 + 32) + 2 + msgUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
} else {
throw new IllegalStateException("Unsupported Text subType: " + st);
}
}
/* ===================================================================== */
/* ========================== Helpers ================================== */
/* ===================================================================== */
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
int len = Short.toUnsignedInt(bb.getShort());
if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
return s;
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
}
/* ====================== BodyHasLine ====================== */
@Override public int lineCode() { return lineCode; }
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
@Override public byte[] prevLineBlockHash32() {
if (prevLineHash32 == null) return null;
return Arrays.copyOf(prevLineHash32, 32);
}
@Override public int lineSeq() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */
@Override public String toBchName() { return toBlockchainName; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
/* ===================================================================== */
/* ===================== Удобные хелперы (для ChainState) =============== */
/* ===================================================================== */
/** true только для POST / EDIT_POST (т.е. это сообщение в линии канала). */
public boolean isLineMessage() {
int st = subType & 0xFFFF;
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
}
/** true только для EDIT_POST / EDIT_REPLY. */
public boolean isEditMessage() {
int st = subType & 0xFFFF;
return st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
}
/** true только для REPLY / EDIT_REPLY (т.е. “не в линии”). */
public boolean isReplyFamily() {
int st = subType & 0xFFFF;
return st == (MsgSubType.TEXT_REPLY & 0xFFFF)
|| st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
}
}
@@ -11,348 +11,174 @@ import java.util.Arrays;
import java.util.Objects;
/**
* TextLineBody — type=1, ver=1.
*
* subType:
* - POST (10)
* - EDIT_POST (11)
* - REPOST (50)
* - CHANNEL_META (90)
* - ENTRYPOINT (100)
* - EXERCISE (110)
* - SERVICE (120)
* - COURSE (130)
*
* Формат bodyBytes (BigEndian):
* Line-based TEXT body.
*
* POST / CHANNEL_META / ENTRYPOINT / EXERCISE / SERVICE / COURSE:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [2] textLenBytes (uint16)
* [N] text UTF-8
* line fields + text
*
* EDIT_POST / REPOST:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [N] text UTF-8
* EDIT_POST (own-chain compact target):
* line fields + [4] targetBlockNumber + [32] targetHash + text
*
* REPOST (external target):
* line fields + [1]loginLen + login + [2]forkNumber + [4]blockNumber + [32]hash + text
*/
public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarget {
public static final short TYPE = 1;
public static final short VER = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header
public final short version; // из header (=1)
// line
public final short subType;
public final short version;
public final int lineCode;
public final int prevLineNumber;
public final byte[] prevLineHash32; // 32 (может быть нули)
public final byte[] prevLineHash32;
public final int thisLineNumber;
// target (для EDIT_POST / REPOST)
public final String toLogin; // nullable для сообщений без target
public final Integer toBlockGlobalNumber; // nullable для POST
public final byte[] toBlockHash32; // nullable для POST
// text
public final String toLogin;
public final Integer toForkNumber;
public final Integer toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
/* ====================== parse from bytes ====================== */
public TextLineBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody version must be 1, got=" + (this.version & 0xFFFF));
}
int st = this.subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META, got subType=" + st);
}
if ((version & 0xFFFF) != 1 || !isSupportedSubType(subType)) throw new IllegalArgumentException("Bad TextLineBody header");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
// минимум line + textLen(2)
ensureMin(bb, (4 + 4 + 32 + 4) + 2, "TextLineBody too short");
BodyTargetSupport.ensureMin(bb, 4 + 4 + 32 + 4 + 2, "TextLineBody too short");
this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.prevLineHash32 = new byte[32]; bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TEXT target missing");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
ensureMin(bb, loginLen + 4 + 32 + 2, "TEXT target payload too short");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
BodyTargetSupport.ensureMin(bb, 4 + 32 + 2, "EDIT_POST target missing");
this.toLogin = null;
this.toForkNumber = null;
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.toBlockHash32 = new byte[32]; bb.get(this.toBlockHash32);
BodyTargetSupport.validateCompact(this.toBlockGlobalNumber, this.toBlockHash32, "EDIT_POST");
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "REPOST", 2);
this.toLogin = t.login();
this.toForkNumber = t.forkNumber();
this.toBlockGlobalNumber = t.blockNumber();
this.toBlockHash32 = t.blockHash32();
} else {
this.toLogin = null;
this.toForkNumber = null;
this.toBlockGlobalNumber = null;
this.toBlockHash32 = null;
}
this.message = readStrictUtf8Len16(bb, "TextLineBody text",
st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF));
ensureNoTail(bb, "TextLineBody");
boolean allowEmpty = st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF);
this.message = readText(bb, allowEmpty);
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected TextLineBody tail");
}
/* ====================== manual ctor ====================== */
public TextLineBody(int lineCode,
int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
short subType,
String toLogin,
Integer toForkNumber,
Integer toBlockGlobalNumber,
byte[] toBlockHash32,
String toLogin,
String message) {
Objects.requireNonNull(message, "message == null");
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF)) {
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST/CHANNEL_META");
}
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad TextLineBody subtype");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (requiresNonBlankMessage(st) && message.isBlank()) {
throw new IllegalArgumentException("message is blank");
}
this.subType = subType;
this.version = VER;
this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
this.prevLineHash32 = prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32);
this.thisLineNumber = thisLineNumber;
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
Objects.requireNonNull(toLogin, "toLogin == null");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else {
this.toLogin = null;
this.toBlockGlobalNumber = null;
this.toBlockHash32 = null;
}
this.toLogin = toLogin;
this.toForkNumber = toForkNumber;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = toBlockHash32 == null ? null : Arrays.copyOf(toBlockHash32, 32);
this.message = message;
check();
}
@Override
public TextLineBody check() {
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)
&& st != (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
&& st != (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
&& st != (MsgSubType.TEXT_EXERCISE & 0xFFFF)
&& st != (MsgSubType.TEXT_SERVICE & 0xFFFF)
&& st != (MsgSubType.TEXT_COURSE & 0xFFFF))
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
@Override public TextLineBody check() {
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad TextLineBody subtype");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (prevLineHash32 == null || prevLineHash32.length != 32)
throw new IllegalArgumentException("prevLineHash32 invalid");
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
if (toLogin != null || toForkNumber != null) throw new IllegalArgumentException("EDIT_POST must not contain login/fork target");
BodyTargetSupport.validateCompact(toBlockGlobalNumber, toBlockHash32, "EDIT_POST");
if (message == null) throw new IllegalArgumentException("EDIT_POST message is null");
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("EDIT_POST toLogin is blank");
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
if (message == null || message.isBlank())
throw new IllegalArgumentException("REPOST message is blank");
if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("REPOST toLogin is blank");
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REPOST");
if (message == null || message.isBlank()) throw new IllegalArgumentException("REPOST message is blank");
} else {
if (toLogin != null || toForkNumber != null || toBlockGlobalNumber != null || toBlockHash32 != null) {
throw new IllegalArgumentException("Non-target text body must not contain target fields");
}
if (st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)) {
if (message == null) throw new IllegalArgumentException("CHANNEL_META message is null");
} else if (requiresNonBlankMessage(st) && (message == null || message.isBlank())) {
} else if (message == null || message.isBlank()) {
throw new IllegalArgumentException("Text message is blank");
} else if (message == null) {
throw new IllegalArgumentException("Text message is null");
}
if (toLogin != null || toBlockGlobalNumber != null || toBlockHash32 != null)
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
}
if (message.getBytes(StandardCharsets.UTF_8).length > 65535) throw new IllegalArgumentException("Text too long");
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
@Override public byte[] toBytes() {
check();
byte[] text = message.getBytes(StandardCharsets.UTF_8);
int st = subType & 0xFFFF;
if (requiresNonBlankMessage(st) && msgUtf8.length == 0) {
throw new IllegalArgumentException("Text payload is empty");
int targetSize = 0;
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) targetSize = 4 + 32;
else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) targetSize = BodyTargetSupport.externalSize(toLogin);
ByteBuffer bb = ByteBuffer.allocate(4 + 4 + 32 + 4 + targetSize + 2 + text.length).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode).putInt(prevLineNumber).put(prevLineHash32).putInt(thisLineNumber);
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
bb.putInt(toBlockGlobalNumber).put(toBlockHash32);
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REPOST");
}
int cap;
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
} else {
if (toLogin == null) throw new IllegalArgumentException("target missing toLogin");
byte[] nameUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("target toLogin utf8 len must be 1..255");
}
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("target missing toBlockGlobalNumber");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("target toBlockHash32 != 32");
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
}
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
}
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
bb.putShort((short) text.length).put(text);
return bb.array();
}
/* ====================== BodyHasLine ====================== */
@Override public int lineCode() { return lineCode; }
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
@Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); }
@Override public int lineSeq() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */
@Override public String toLogin() { return toLogin; }
@Override public Integer toForkNumber() { return toForkNumber; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32 == null ? null : Arrays.copyOf(toBlockHash32, 32); }
public boolean isEditPost() { return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF); }
/* ====================== helpers ====================== */
public boolean isEditPost() {
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
private static boolean isSupportedSubType(short subType) {
int st = subType & 0xFFFF;
return st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF) || st == (MsgSubType.TEXT_CHANNEL_META & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) || st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF) || st == (MsgSubType.TEXT_COURSE & 0xFFFF);
}
private static boolean requiresNonBlankMessage(int st) {
return st == (MsgSubType.TEXT_POST & 0xFFFF)
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)
|| st == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| st == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF);
}
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
private static String readText(ByteBuffer bb, boolean allowEmpty) {
if (bb.remaining() < 2) throw new IllegalArgumentException("Text length missing");
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) {
if (allowEmpty) return "";
throw new IllegalArgumentException(fieldName + " is empty");
}
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
return s;
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
if (len == 0) { if (allowEmpty) return ""; throw new IllegalArgumentException("Text is empty"); }
if (bb.remaining() < len) throw new IllegalArgumentException("Text payload too short");
byte[] bytes = new byte[len]; bb.get(bytes);
var dec = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
try { String s = dec.decode(ByteBuffer.wrap(bytes)).toString(); if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException("Text blank"); return s; }
catch (CharacterCodingException e) { throw new IllegalArgumentException("Text invalid UTF-8", e); }
}
}
@@ -10,19 +10,8 @@ import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* TextRatingBody — type=1, ver=1, subType=30.
*
* Формат bodyBytes (BigEndian):
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*/
/** TEXT_RATING. External target = login + forkNumber + blockNumber + hash32. */
public final class TextRatingBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
@@ -30,6 +19,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
public final short subType;
public final short version;
public final String toLogin;
public final int toForkNumber;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
@@ -38,107 +28,60 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextRatingBody version must be 1, got=" + (version & 0xFFFF));
}
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING");
}
if ((version & 0xFFFF) != (VER & 0xFFFF)) throw new IllegalArgumentException("TextRatingBody version must be 1");
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) throw new IllegalArgumentException("TextRatingBody supports only TEXT_RATING");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("RATING toLoginLen is 0");
ensureMin(bb, loginLen + 4 + 32 + 2, "RATING payload too short");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
ensureNoTail(bb, "TextRatingBody");
BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "RATING", 2);
this.toLogin = t.login();
this.toForkNumber = t.forkNumber();
this.toBlockGlobalNumber = t.blockNumber();
this.toBlockHash32 = t.blockHash32();
this.message = readText(bb);
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected RATING tail");
}
public TextRatingBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
public TextRatingBody(String toLogin, int toForkNumber, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(message, "message == null");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "RATING");
this.subType = MsgSubType.TEXT_RATING;
this.version = VER;
this.toLogin = toLogin;
this.toForkNumber = toForkNumber;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
}
@Override
public TextRatingBody check() {
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF))
throw new IllegalArgumentException("Bad RATING subType");
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("RATING toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
@Override public TextRatingBody check() {
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) throw new IllegalArgumentException("Bad RATING subType");
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "RATING");
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
if (message.getBytes(StandardCharsets.UTF_8).length > 65535) throw new IllegalArgumentException("Text too long");
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("RATING toLogin utf8 len must be 1..255");
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
@Override public byte[] toBytes() {
check();
byte[] text = message.getBytes(StandardCharsets.UTF_8);
ByteBuffer bb = ByteBuffer.allocate(BodyTargetSupport.externalSize(toLogin) + 2 + text.length).order(ByteOrder.BIG_ENDIAN);
BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "RATING");
bb.putShort((short) text.length).put(text);
return bb.array();
}
@Override public String toLogin() { return toLogin; }
@Override public Integer toForkNumber() { return toForkNumber; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@Override public byte[] toBlockHashBytes() { return Arrays.copyOf(toBlockHash32, 32); }
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
private static String readText(ByteBuffer bb) {
if (bb.remaining() < 2) throw new IllegalArgumentException("RATING text length missing");
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
return s;
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
if (len <= 0 || bb.remaining() < len) throw new IllegalArgumentException("RATING text invalid");
byte[] bytes = new byte[len]; bb.get(bytes);
var dec = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
try { String s = dec.decode(ByteBuffer.wrap(bytes)).toString(); if (s.isBlank()) throw new IllegalArgumentException("RATING text blank"); return s; }
catch (CharacterCodingException e) { throw new IllegalArgumentException("RATING text invalid UTF-8", e); }
}
}
@@ -11,32 +11,19 @@ import java.util.Arrays;
import java.util.Objects;
/**
* TextReplyBody — type=1, ver=1.
*
* subType:
* - REPLY (20)
* - EDIT_REPLY (21)
*
* Оба subtype используют одинаковый target-формат:
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*
* Для EDIT_REPLY text может быть пустым (логическое удаление).
* TEXT_REPLY / TEXT_EDIT_REPLY.
* REPLY external target: login + forkNumber + blockNumber + hash32.
* EDIT_REPLY compact own-chain target: blockNumber + hash32.
*/
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 1;
public static final short VER = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType;
public final short version;
public final String toLogin;
public final Integer toForkNumber;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
@@ -45,135 +32,90 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
}
int st = this.subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
}
if ((version & 0xFFFF) != (VER & 0xFFFF)) throw new IllegalArgumentException("TextReplyBody version must be 1");
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) throw new IllegalArgumentException("Bad reply subtype");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TextReplyBody too short");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("TextReplyBody toLoginLen is 0");
ensureMin(bb, loginLen + 4 + 32 + 2, "TextReplyBody payload too short");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
ensureNoTail(bb, "TextReplyBody");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
BodyTargetSupport.ExternalTarget t = BodyTargetSupport.readExternal(bb, "REPLY", 2);
this.toLogin = t.login();
this.toForkNumber = t.forkNumber();
this.toBlockGlobalNumber = t.blockNumber();
this.toBlockHash32 = t.blockHash32();
} else {
BodyTargetSupport.ensureMin(bb, 4 + 32 + 2, "EDIT_REPLY too short");
this.toLogin = null;
this.toForkNumber = null;
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
BodyTargetSupport.validateCompact(this.toBlockGlobalNumber, this.toBlockHash32, "EDIT_REPLY");
}
this.message = readText(bb, st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
if (bb.hasRemaining()) throw new IllegalArgumentException("Unexpected TextReplyBody tail");
}
public TextReplyBody(short subType,
int toBlockGlobalNumber,
byte[] toBlockHash32,
String toLogin,
String message) {
public TextReplyBody(short subType, String toLogin, Integer toForkNumber, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(message, "message == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(toLogin, "toLogin == null");
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY");
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) throw new IllegalArgumentException("Bad reply subtype");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (message.isBlank()) throw new IllegalArgumentException("REPLY message is blank");
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REPLY");
} else {
if (toLogin != null || toForkNumber != null) throw new IllegalArgumentException("EDIT_REPLY must not contain login/fork target");
BodyTargetSupport.validateCompact(toBlockGlobalNumber, toBlockHash32, "EDIT_REPLY");
}
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
throw new IllegalArgumentException("message is blank");
}
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.subType = subType;
this.version = VER;
this.toLogin = toLogin;
this.toForkNumber = toForkNumber;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
}
@Override
public TextReplyBody check() {
@Override public TextReplyBody check() {
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank");
} else if (message == null) {
throw new IllegalArgumentException("EDIT_REPLY message is null");
}
BodyTargetSupport.validateExternal(toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REPLY");
if (message == null || message.isBlank()) throw new IllegalArgumentException("REPLY message is blank");
} else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
if (toLogin != null || toForkNumber != null) throw new IllegalArgumentException("EDIT_REPLY must not contain login/fork target");
BodyTargetSupport.validateCompact(toBlockGlobalNumber, toBlockHash32, "EDIT_REPLY");
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
} else throw new IllegalArgumentException("Bad reply subtype");
if (message.getBytes(StandardCharsets.UTF_8).length > 65535) throw new IllegalArgumentException("Text too long");
return this;
}
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
throw new IllegalArgumentException("Text payload is empty");
}
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("TextReplyBody toLogin utf8 len must be 1..255");
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
@Override public byte[] toBytes() {
check();
byte[] text = message.getBytes(StandardCharsets.UTF_8);
boolean reply = (subType & 0xFFFF) == (MsgSubType.TEXT_REPLY & 0xFFFF);
int targetSize = reply ? BodyTargetSupport.externalSize(toLogin) : 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(targetSize + 2 + text.length).order(ByteOrder.BIG_ENDIAN);
if (reply) BodyTargetSupport.writeExternal(bb, toLogin, toForkNumber, toBlockGlobalNumber, toBlockHash32, "REPLY");
else bb.putInt(toBlockGlobalNumber).put(toBlockHash32);
bb.putShort((short) text.length).put(text);
return bb.array();
}
@Override public String toLogin() { return toLogin; }
@Override public Integer toForkNumber() { return toForkNumber; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@Override public byte[] toBlockHashBytes() { return Arrays.copyOf(toBlockHash32, 32); }
public boolean isEditReply() { return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); }
public boolean isEditReply() {
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
}
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
private static String readText(ByteBuffer bb, boolean allowEmpty) {
if (bb.remaining() < 2) throw new IllegalArgumentException("Text length missing");
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) {
if (allowEmpty) return "";
throw new IllegalArgumentException(fieldName + " is empty");
}
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
return s;
} catch (CharacterCodingException e) {
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
}
}
private static void ensureMin(ByteBuffer bb, int need, String msg) {
if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")");
}
private static void ensureNoTail(ByteBuffer bb, String ctx) {
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining());
if (len == 0) { if (allowEmpty) return ""; throw new IllegalArgumentException("Text is empty"); }
if (bb.remaining() < len) throw new IllegalArgumentException("Text payload too short");
byte[] bytes = new byte[len]; bb.get(bytes);
var dec = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
try { String s = dec.decode(ByteBuffer.wrap(bytes)).toString(); if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException("Text is blank"); return s; }
catch (CharacterCodingException e) { throw new IllegalArgumentException("Text invalid UTF-8", e); }
}
}
@@ -1,71 +1,54 @@
package utils.blockchain;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Canonical physical fork name helper: login-NNN, forks are limited to 001..999. */
public final class BlockchainNameUtil {
/**
* Теперь новое правило:
* blockchainName = login + "-"+ 3 цифры
* Пример: "Dima-001" -> "Dima"
*
* Сколько символов отрезаем с конца blockchainName, чтобы получить login: "-001" = 4
*/
public static final int BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN = 4;
private static final Pattern SUFFIX = Pattern.compile("^(.*)-(\\d{3})$");
public static final int MIN_FORK_NUMBER = 1;
public static final int MAX_FORK_NUMBER = 999;
private BlockchainNameUtil() {}
/**
* Извлечь login из blockchainName: отрезаем последние 4 символа ("-NNN").
* Пример: "Dima-001" -> "Dima"
*/
public static String loginFromBlockchainName(String blockchainName) {
if (blockchainName == null) return null;
Matcher m = matcher(blockchainName);
return m == null ? null : m.group(1);
}
String s = blockchainName.trim();
if (!hasDashAnd3DigitsSuffix(s)) return null;
return s.substring(0, s.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN);
public static Integer forkNumberFromBlockchainName(String blockchainName) {
Matcher m = matcher(blockchainName);
if (m == null) return null;
int fork = Integer.parseInt(m.group(2));
return fork >= MIN_FORK_NUMBER && fork <= MAX_FORK_NUMBER ? fork : null;
}
/**
* Проверка правила:
* - blockchainName должен оканчиваться на "-"+3 цифры
* - blockchainName без суффикса "-NNN" должен равняться login
*
* ВАЖНО:
* - сравнение строгое (case-sensitive)
* - null/blank считаем невалидным
*/
public static boolean isBlockchainNameMatchesLogin(String blockchainName, String login) {
if (blockchainName == null || login == null) return false;
String bn = blockchainName.trim();
String lg = login.trim();
if (bn.isEmpty() || lg.isEmpty()) return false;
if (!hasDashAnd3DigitsSuffix(bn)) return false;
String extracted = bn.substring(0, bn.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN);
return Objects.equals(extracted, lg);
if (login == null || login.isBlank()) return false;
String extracted = loginFromBlockchainName(blockchainName);
return extracted != null && Objects.equals(extracted, login.trim());
}
private static boolean hasDashAnd3DigitsSuffix(String s) {
if (s == null) return false;
int len = s.length();
if (len <= BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN) return false;
int dashPos = len - 4;
if (s.charAt(dashPos) != '-') return false;
char c1 = s.charAt(len - 3);
char c2 = s.charAt(len - 2);
char c3 = s.charAt(len - 1);
return isDigit(c1) && isDigit(c2) && isDigit(c3);
public static String blockchainName(String login, int forkNumber) {
if (login == null || login.isBlank()) throw new IllegalArgumentException("login is blank");
if (forkNumber < MIN_FORK_NUMBER || forkNumber > MAX_FORK_NUMBER) {
throw new IllegalArgumentException("forkNumber must be 1..999");
}
return login.trim() + "-" + String.format("%03d", forkNumber);
}
private static boolean isDigit(char c) {
return c >= '0' && c <= '9';
private static Matcher matcher(String blockchainName) {
if (blockchainName == null) return null;
String s = blockchainName.trim();
if (s.isEmpty()) return null;
Matcher m = SUFFIX.matcher(s);
if (!m.matches() || m.group(1).isBlank()) return null;
try {
int fork = Integer.parseInt(m.group(2));
return fork >= MIN_FORK_NUMBER && fork <= MAX_FORK_NUMBER ? m : null;
} catch (NumberFormatException e) {
return null;
}
}
}
}
@@ -1,9 +1,11 @@
package blockchain;
import blockchain.body.ConnectionBody;
import blockchain.body.HeaderBody;
import blockchain.body.ReactionBody;
import blockchain.body.StatusActionBody;
import blockchain.body.TextLineBody;
import blockchain.body.TextRatingBody;
import blockchain.body.TextReplyBody;
import org.junit.jupiter.api.Test;
@@ -14,55 +16,86 @@ import static org.junit.jupiter.api.Assertions.*;
final class TargetLoginFormatTest {
private static final byte[] HASH = hash(7);
private static final byte[] INITIAL_KEY = hash(11);
@Test
void reactionRoundTripStoresLoginNotBlockchainName() {
ReactionBody source = new ReactionBody("alice", 42, HASH);
void externalTargetRoundTripKeepsHistoricalForkMetadata() {
ReactionBody source = new ReactionBody(MsgSubType.REACTION_LIKE, "alice", 7, 42, HASH);
ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check();
assertEquals("alice", parsed.toLogin());
assertEquals(7, parsed.toForkNumber());
assertEquals(42, parsed.toBlockGlobalNumber());
assertArrayEquals(HASH, parsed.toBlockHashBytes());
assertFalse(new String(source.toBytes()).contains("alice-001"));
assertFalse(new String(source.toBytes()).contains("alice-007"));
}
@Test
void replyAndEditReplyUseSameLoginTargetShape() {
TextReplyBody reply = new TextReplyBody(MsgSubType.TEXT_REPLY, 11, HASH, "alice", "reply");
void replyIsExternalButEditReplyIsCompactOwnChainTarget() {
TextReplyBody reply = new TextReplyBody(MsgSubType.TEXT_REPLY, "alice", 12, 11, HASH, "reply");
TextReplyBody parsedReply = new TextReplyBody(MsgSubType.TEXT_REPLY, TextReplyBody.VER, reply.toBytes()).check();
assertEquals("alice", parsedReply.toLogin());
assertEquals(12, parsedReply.toForkNumber());
TextReplyBody edit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, 11, HASH, "alice", "");
TextReplyBody edit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, null, null, 11, HASH, "");
TextReplyBody parsedEdit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, TextReplyBody.VER, edit.toBytes()).check();
assertEquals("alice", parsedEdit.toLogin());
assertEquals("", parsedEdit.message);
assertNull(parsedEdit.toLogin());
assertNull(parsedEdit.toForkNumber());
assertEquals(11, parsedEdit.toBlockGlobalNumber());
assertArrayEquals(HASH, parsedEdit.toBlockHashBytes());
assertTrue(edit.toBytes().length < reply.toBytes().length);
}
@Test
void editPostAndConnectionCarryLoginTarget() {
void editPostIsCompactAndConnectionUsesRealExternalTarget() {
TextLineBody editPost = new TextLineBody(
0, -1, new byte[32], -1,
MsgSubType.TEXT_EDIT_POST,
5, HASH, "alice", "edited"
null, null, 5, HASH, "edited"
);
TextLineBody parsedEditPost = new TextLineBody(MsgSubType.TEXT_EDIT_POST, TextLineBody.VER, editPost.toBytes()).check();
assertEquals("alice", parsedEditPost.toLogin());
assertNull(parsedEditPost.toLogin());
assertNull(parsedEditPost.toForkNumber());
assertEquals(5, parsedEditPost.toBlockGlobalNumber());
ConnectionBody connection = new ConnectionBody(
0, -1, new byte[32], -1,
MsgSubType.CONNECTION_FOLLOW,
"alice", 0, HASH
"alice", 3, 0, HASH
);
ConnectionBody parsedConnection = new ConnectionBody(MsgSubType.CONNECTION_FOLLOW, ConnectionBody.VER, connection.toBytes()).check();
assertEquals("alice", parsedConnection.toLogin());
assertEquals(3, parsedConnection.toForkNumber());
assertEquals(0, parsedConnection.toBlockGlobalNumber());
assertArrayEquals(HASH, parsedConnection.toBlockHashBytes());
}
@Test
void statusActionCarriesLoginTarget() {
StatusActionBody source = new StatusActionBody(MsgSubType.STATUS_STARTED, "alice", 77, HASH, "");
StatusActionBody parsed = new StatusActionBody(MsgSubType.STATUS_STARTED, StatusActionBody.VER, source.toBytes()).check();
assertEquals("alice", parsed.toLogin());
assertEquals(77, parsed.toBlockGlobalNumber());
void ratingAndStatusCarryHistoricalForkNumber() {
TextRatingBody rating = new TextRatingBody("alice", 9, 70, HASH, "5");
TextRatingBody parsedRating = new TextRatingBody(MsgSubType.TEXT_RATING, TextRatingBody.VER, rating.toBytes()).check();
assertEquals(9, parsedRating.toForkNumber());
StatusActionBody status = new StatusActionBody(MsgSubType.STATUS_STARTED, "alice", 9, 77, HASH, "");
StatusActionBody parsedStatus = new StatusActionBody(MsgSubType.STATUS_STARTED, StatusActionBody.VER, status.toBytes()).check();
assertEquals("alice", parsedStatus.toLogin());
assertEquals(9, parsedStatus.toForkNumber());
assertEquals(77, parsedStatus.toBlockGlobalNumber());
}
@Test
void forkZeroIsReservedButStillDecodableAsHistoricalUnknown() {
ReactionBody source = new ReactionBody(MsgSubType.REACTION_LIKE, "alice", 0, 42, HASH);
ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check();
assertEquals(0, parsed.toForkNumber());
}
@Test
void headerCarriesInitialBlockchainKey() {
HeaderBody source = new HeaderBody("alice", INITIAL_KEY);
HeaderBody parsed = new HeaderBody(HeaderBody.SUBTYPE_COMPAT, HeaderBody.VER, source.toBytes()).check();
assertEquals("alice", parsed.login);
assertArrayEquals(INITIAL_KEY, parsed.initialBlockchainKey32);
}
private static byte[] hash(int seed) {
@@ -20,8 +20,8 @@ public final class ShineSignatureConstants {
/** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */
public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:";
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
/** TAG в HeaderBody (genesis). ASCII "SHiNE". */
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNE";
/** DOMAIN для preimage при расчёте hash32 блока. ASCII "SHiNe". */
public static final String BLOCK_HASH_DOMAIN = "SHiNe";
@@ -17,60 +17,12 @@ import java.util.List;
public final class DatabaseInitializer {
public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version";
public static final String DB_FORMAT_MARKER_TABLE = "shine_database_metadata";
public static final String DB_FORMAT_MARKER = "SHiNE_DB_V2";
public static final int DB_FORMAT_VERSION = 2;
public static final int SCHEMA_VERSION_1 = 1;
public static final int SCHEMA_VERSION_2 = 2;
public static final int SCHEMA_VERSION_3 = 3;
public static final int SCHEMA_VERSION_4 = 4;
public static final int SCHEMA_VERSION_5 = 5;
public static final int SCHEMA_VERSION_6 = 6;
public static final int SCHEMA_VERSION_7 = 7;
public static final int SCHEMA_VERSION_8 = 8;
public static final int SCHEMA_VERSION_9 = 9;
public static final int SCHEMA_VERSION_10 = 10;
public static final int SCHEMA_VERSION_11 = 11;
public static final int SCHEMA_VERSION_12 = 12;
public static final int SCHEMA_VERSION_13 = 13;
public static final int SCHEMA_VERSION_14 = 14;
public static final int SCHEMA_VERSION_15 = 15;
public static final int SCHEMA_VERSION_16 = 16;
public static final int SCHEMA_VERSION_17 = 17;
public static final int SCHEMA_VERSION_18 = 18;
public static final int SCHEMA_VERSION_19 = 19;
public static final int SCHEMA_VERSION_20 = 20;
public static final int SCHEMA_VERSION_21 = 21;
public static final int SCHEMA_VERSION_22 = 22;
public static final int SCHEMA_VERSION_23 = 23;
public static final int SCHEMA_VERSION_24 = 24;
public static final int SCHEMA_VERSION_25 = 25;
public static final int SCHEMA_VERSION_26 = 26;
public static final int SCHEMA_VERSION_27 = 27;
public static final int SCHEMA_VERSION_29 = 29;
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
public static final String POSTGRES_MIGRATION_V4_RESOURCE = "postgres/migration_v4.sql";
public static final String POSTGRES_MIGRATION_V5_RESOURCE = "postgres/migration_v5.sql";
public static final String POSTGRES_MIGRATION_V6_RESOURCE = "postgres/migration_v6.sql";
public static final String POSTGRES_MIGRATION_V7_RESOURCE = "postgres/migration_v7.sql";
public static final String POSTGRES_MIGRATION_V8_RESOURCE = "postgres/migration_v8.sql";
public static final String POSTGRES_MIGRATION_V9_RESOURCE = "postgres/migration_v9.sql";
public static final String POSTGRES_MIGRATION_V10_RESOURCE = "postgres/migration_v10.sql";
public static final String POSTGRES_MIGRATION_V11_RESOURCE = "postgres/migration_v11.sql";
public static final String POSTGRES_MIGRATION_V12_RESOURCE = "postgres/migration_v12.sql";
public static final String POSTGRES_MIGRATION_V13_RESOURCE = "postgres/migration_v13.sql";
public static final String POSTGRES_MIGRATION_V14_RESOURCE = "postgres/migration_v14.sql";
public static final String POSTGRES_MIGRATION_V15_RESOURCE = "postgres/migration_v15.sql";
public static final String POSTGRES_MIGRATION_V16_RESOURCE = "postgres/migration_v16.sql";
public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.sql";
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql";
public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql";
public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql";
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
public static final String POSTGRES_MIGRATION_V26_RESOURCE = "postgres/migration_v26.sql";
public static final String POSTGRES_MIGRATION_V27_RESOURCE = "postgres/migration_v27.sql";
private DatabaseInitializer() {}
@@ -139,114 +91,31 @@ public final class DatabaseInitializer {
}
try (Connection conn = openConnection(jdbcUrl, user, password)) {
if (!postgresSchemaVersionTableExists(conn)) {
boolean markerTableExists = postgresTableExists(conn, DB_FORMAT_MARKER_TABLE);
boolean schemaVersionTableExists = postgresTableExists(conn, DB_SCHEMA_VERSION_TABLE);
if (!markerTableExists) {
if (schemaVersionTableExists || currentSchemaHasBaseTables(conn)) {
throw new SQLException(
"Legacy/non-v2 SHiNE PostgreSQL database detected. "
+ "Stage 2 requires an empty database initialized as " + DB_FORMAT_MARKER
+ "; automatic migration from the old database is intentionally disabled.");
}
runSqlScript(conn, POSTGRES_SCHEMA_RESOURCE);
return;
markerTableExists = postgresTableExists(conn, DB_FORMAT_MARKER_TABLE);
schemaVersionTableExists = postgresTableExists(conn, DB_SCHEMA_VERSION_TABLE);
}
if (!markerTableExists || !schemaVersionTableExists) {
throw new SQLException(
"Invalid SHiNE PostgreSQL database: required v2 metadata/schema tables are missing.");
}
validateDatabaseFormatV2(conn);
int currentVersion = readCurrentSchemaVersion(conn);
if (currentVersion < SCHEMA_VERSION_2) {
runSqlScript(conn, POSTGRES_MIGRATION_V2_RESOURCE);
currentVersion = SCHEMA_VERSION_2;
}
if (currentVersion < SCHEMA_VERSION_3) {
runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE);
currentVersion = SCHEMA_VERSION_3;
}
if (currentVersion < SCHEMA_VERSION_4) {
runSqlScript(conn, POSTGRES_MIGRATION_V4_RESOURCE);
currentVersion = SCHEMA_VERSION_4;
}
if (currentVersion < SCHEMA_VERSION_5) {
runSqlScript(conn, POSTGRES_MIGRATION_V5_RESOURCE);
currentVersion = SCHEMA_VERSION_5;
}
if (currentVersion < SCHEMA_VERSION_6) {
runSqlScript(conn, POSTGRES_MIGRATION_V6_RESOURCE);
currentVersion = SCHEMA_VERSION_6;
}
if (currentVersion < SCHEMA_VERSION_7) {
runSqlScript(conn, POSTGRES_MIGRATION_V7_RESOURCE);
currentVersion = SCHEMA_VERSION_7;
}
if (currentVersion < SCHEMA_VERSION_8) {
runSqlScript(conn, POSTGRES_MIGRATION_V8_RESOURCE);
currentVersion = SCHEMA_VERSION_8;
}
if (currentVersion < SCHEMA_VERSION_9) {
runSqlScript(conn, POSTGRES_MIGRATION_V9_RESOURCE);
currentVersion = SCHEMA_VERSION_9;
}
if (currentVersion < SCHEMA_VERSION_10) {
runSqlScript(conn, POSTGRES_MIGRATION_V10_RESOURCE);
currentVersion = SCHEMA_VERSION_10;
}
if (currentVersion < SCHEMA_VERSION_11) {
runSqlScript(conn, POSTGRES_MIGRATION_V11_RESOURCE);
currentVersion = SCHEMA_VERSION_11;
}
if (currentVersion < SCHEMA_VERSION_12) {
runSqlScript(conn, POSTGRES_MIGRATION_V12_RESOURCE);
currentVersion = SCHEMA_VERSION_12;
}
if (currentVersion < SCHEMA_VERSION_13) {
runSqlScript(conn, POSTGRES_MIGRATION_V13_RESOURCE);
currentVersion = SCHEMA_VERSION_13;
}
if (currentVersion < SCHEMA_VERSION_14) {
runSqlScript(conn, POSTGRES_MIGRATION_V14_RESOURCE);
currentVersion = SCHEMA_VERSION_14;
}
if (currentVersion < SCHEMA_VERSION_15) {
runSqlScript(conn, POSTGRES_MIGRATION_V15_RESOURCE);
currentVersion = SCHEMA_VERSION_15;
}
if (currentVersion < SCHEMA_VERSION_16) {
runSqlScript(conn, POSTGRES_MIGRATION_V16_RESOURCE);
currentVersion = SCHEMA_VERSION_16;
}
if (currentVersion < SCHEMA_VERSION_17) {
runSqlScript(conn, POSTGRES_MIGRATION_V17_RESOURCE);
currentVersion = SCHEMA_VERSION_17;
}
if (currentVersion < SCHEMA_VERSION_18) {
runSqlScript(conn, POSTGRES_MIGRATION_V18_RESOURCE);
currentVersion = SCHEMA_VERSION_18;
}
if (currentVersion < SCHEMA_VERSION_19) {
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
currentVersion = SCHEMA_VERSION_19;
}
if (currentVersion < SCHEMA_VERSION_20) {
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
currentVersion = SCHEMA_VERSION_20;
}
if (currentVersion < SCHEMA_VERSION_21) {
runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE);
currentVersion = SCHEMA_VERSION_21;
}
if (currentVersion < SCHEMA_VERSION_22) {
runSqlScript(conn, POSTGRES_MIGRATION_V22_RESOURCE);
currentVersion = SCHEMA_VERSION_22;
}
if (currentVersion < SCHEMA_VERSION_23) {
runSqlScript(conn, POSTGRES_MIGRATION_V23_RESOURCE);
currentVersion = SCHEMA_VERSION_23;
}
if (currentVersion < SCHEMA_VERSION_24) {
runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE);
currentVersion = SCHEMA_VERSION_24;
}
if (currentVersion < SCHEMA_VERSION_25) {
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
currentVersion = SCHEMA_VERSION_25;
}
if (currentVersion < SCHEMA_VERSION_26) {
runSqlScript(conn, POSTGRES_MIGRATION_V26_RESOURCE);
currentVersion = SCHEMA_VERSION_26;
}
if (currentVersion < SCHEMA_VERSION_27) {
runSqlScript(conn, POSTGRES_MIGRATION_V27_RESOURCE);
currentVersion = SCHEMA_VERSION_27;
if (currentVersion != SCHEMA_VERSION_29) {
throw new SQLException(
"Unsupported SHiNE DB v2 schema version: expected 29, found " + currentVersion);
}
}
}
@@ -258,23 +127,60 @@ public final class DatabaseInitializer {
return DriverManager.getConnection(jdbcUrl, user, password == null ? "" : password);
}
private static boolean postgresSchemaVersionTableExists(Connection conn) throws SQLException {
private static boolean postgresTableExists(Connection conn, String tableName) throws SQLException {
String sql = """
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = ?
AND table_type = 'BASE TABLE'
)
""";
try (var ps = conn.prepareStatement(sql)) {
ps.setString(1, DB_SCHEMA_VERSION_TABLE);
ps.setString(1, tableName);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() && rs.getBoolean(1);
}
}
}
private static boolean currentSchemaHasBaseTables(Connection conn) throws SQLException {
String sql = """
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_type = 'BASE TABLE'
)
""";
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
return rs.next() && rs.getBoolean(1);
}
}
private static void validateDatabaseFormatV2(Connection conn) throws SQLException {
String sql = """
SELECT database_format, database_generation
FROM shine_database_metadata
WHERE id = 1
""";
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
if (!rs.next()) {
throw new SQLException(
"Invalid SHiNE PostgreSQL database: SHiNE_DB_V2 metadata row is missing.");
}
String format = rs.getString("database_format");
int generation = rs.getInt("database_generation");
if (!DB_FORMAT_MARKER.equals(format) || generation != DB_FORMAT_VERSION) {
throw new SQLException(
"Unsupported SHiNE PostgreSQL database format: expected " + DB_FORMAT_MARKER
+ " generation " + DB_FORMAT_VERSION
+ ", found format=" + format + ", generation=" + generation);
}
}
}
private static int readCurrentSchemaVersion(Connection conn) throws SQLException {
String sql = """
SELECT schema_version
@@ -4,7 +4,7 @@ package shine.db;
* MsgSubType — единое место для ВСЕХ subType сообщений (msg_sub_type).
*
* ВАЖНО:
* - Значения должны совпадать с body-классами (TextBody/ReactionBody/ConnectionBody/UserParamBody/HeaderBody).
* - Значения должны совпадать с body-классами (TextLineBody/TextReplyBody/TextRatingBody/ReactionBody/ConnectionBody/UserParamBody/HeaderBody).
* - После релиза менять числа нельзя (иначе ломается совместимость данных).
*/
public final class MsgSubType {
@@ -28,7 +28,7 @@ public final class PostgresDbController {
try {
DatabaseInitializer.ensurePostgresSchemaInitialized(jdbcUrl, dbUser, dbPassword);
} catch (SQLException e) {
throw new RuntimeException("PostgreSQL schema auto-init failed", e);
throw new RuntimeException("PostgreSQL database compatibility/init failed: " + e.getMessage(), e);
}
this.delegate = new DriverManagerDbProvider(jdbcUrl, dbUser, dbPassword, connection -> {
@@ -1,603 +1,124 @@
package shine.db.dao;
import shine.db.DatabaseInitializer;
import shine.db.DbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.*;
/**
* BlockchainResyncCleanupDAO — подготовительный "жёсткий reset" одной blockchain-цепочки
* перед её полной повторной загрузкой от сервера-партнёра.
*
* Что делает этот DAO:
* 1) в ОДНОЙ SQL-транзакции сначала аккуратно уменьшает чужие агрегаты,
* которые были увеличены блоками удаляемой цепочки:
* - likes_count
* - replies_count
* 2) затем удаляет все локальные записи самой цепочки и её производные состояния.
*
* Почему это вынесено в отдельный DAO-метод, а не в триггеры DELETE:
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
* - пользовательские blockchain-файлы больше не существуют; cleanup полностью DB-only.
* SQL-транзакция и файловая система не коммитятся атомарно вместе;
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
* отдельным recovery/resync-слоем после успешного commit.
*
* Важный смысл текущей реализации:
* - мы НЕ трогаем current users слой (`solana_user_pda_current`) и НЕ трогаем DM-таблицы;
* - мы очищаем только блокчейн пользователя и derived-state, который строится из неё;
* - висячие cross-chain ссылки в чужих blocks допускаются как нормальное поведение системы.
* Atomic cleanup of one physical fork before deterministic replay.
* Physical fork identity is (login,fork_number). Incoming logical state is never rebound/deleted.
*/
public final class BlockchainResyncCleanupDAO {
private static final int BLOCKCHAIN_LOGIN_SUFFIX_LEN = 4; // "-001"
private static volatile BlockchainResyncCleanupDAO instance;
private final DbController db=DbController.getInstance();
private BlockchainResyncCleanupDAO(){}
public static BlockchainResyncCleanupDAO getInstance(){ if(instance==null) synchronized(BlockchainResyncCleanupDAO.class){if(instance==null)instance=new BlockchainResyncCleanupDAO();} return instance; }
private final DbController db = DbController.getInstance();
private BlockchainResyncCleanupDAO() {}
public static BlockchainResyncCleanupDAO getInstance() {
if (instance == null) {
synchronized (BlockchainResyncCleanupDAO.class) {
if (instance == null) instance = new BlockchainResyncCleanupDAO();
}
}
return instance;
}
/**
* Полностью очищает одну blockchain-цепочку и локальные derived-state, собранные из неё.
*
* Порядок внутри транзакции намеренно такой:
* 1. Сначала уменьшаем чужие likes_count для тех целей, где финальное состояние
* реакции этой цепочки было LIKE.
* 2. Сначала уменьшаем чужие replies_count для reply-блоков этой цепочки.
* 3. После этого удаляем локальные derived-state самой цепочки.
* 4. В конце удаляем blocks и blockchain_state.
*
* Это правильно потому, что агрегаты (`message_stats`) должны видеть исходные blocks
* и reactions_state на момент пересчёта. Если удалить blocks раньше, мы потеряем
* источник правды для корректного уменьшения счётчиков.
*
* Метод идемпотентен по смыслу:
* - если часть данных уже удалена раньше, повторный вызов просто удалит "0 строк";
* - если blockchain_state уже отсутствует, login берём из blockchainName.
*
* Отдельно важно:
* - filesystem blockchain storage отсутствует;
* - здесь НЕТ повторной загрузки цепочки;
* - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow.
*/
public CleanupResult cleanupBlockchainForFullResync(String blockchainName) throws SQLException {
if (blockchainName == null || blockchainName.isBlank()) {
throw new IllegalArgumentException("blockchainName is blank");
}
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
String login = resolveLoginForCleanup(c, blockchainName);
int likesAdjusted = decreaseForeignLikesCount(c, blockchainName);
int repliesAdjusted = decreaseForeignRepliesCount(c, blockchainName);
int deletedMessageStats = deleteMessageStatsForOwnTargets(c, blockchainName);
int deletedReactionsState = deleteReactionsStateForActorChain(c, blockchainName);
int deletedConnectionsState = deleteConnectionsStateForLogin(c, login);
int deletedUsersParams = deleteUsersParamsForLogin(c, login);
int deletedChannelNames = deleteChannelNamesForOwnerChain(c, blockchainName);
int deletedChat200State = deleteChat200StateForOwnerChain(c, blockchainName);
int deletedChat200Members = deleteChat200MembersForOwnerChain(c, blockchainName);
int deletedBlocks = deleteBlocksForChain(c, blockchainName);
int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName);
rebuildStatsState(c);
c.commit();
return new CleanupResult(
login,
likesAdjusted,
repliesAdjusted,
deletedMessageStats,
deletedReactionsState,
deletedConnectionsState,
deletedUsersParams,
deletedChannelNames,
deletedChat200State,
deletedChat200Members,
deletedBlocks,
deletedBlockchainState
);
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
if (e instanceof SQLException sqlEx) throw sqlEx;
throw new SQLException("Не удалось очистить blockchain для полного resync: " + blockchainName, e);
} finally {
try { c.setAutoCommit(oldAutoCommit); } catch (Exception ignored) {}
}
}
}
/**
* После смены fork обновляет только runtime-кэш физического blockchainName у логических target-ссылок.
* Подписанная идентичность target уже задаётся login + blockNumber + blockHash; поэтому смена cache value
* не меняет смысл ссылки и не требует переподписывать чужие блоки.
*/
public void refreshLogicalTargetBlockchainCache(String login, String oldBlockchainName, String newBlockchainName) throws SQLException {
if (login == null || login.isBlank() || newBlockchainName == null || newBlockchainName.isBlank()) {
throw new IllegalArgumentException("login/newBlockchainName are required");
}
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
updateTargetCacheByLogin(c, "blocks", login, newBlockchainName);
updateTargetCacheByLogin(c, "connections_state", login, newBlockchainName);
updateTargetCacheByLogin(c, "reactions_state", login, newBlockchainName);
updateTargetCacheByLogin(c, "message_stats", login, newBlockchainName);
if (oldBlockchainName != null && !oldBlockchainName.isBlank()) {
try (PreparedStatement ps = c.prepareStatement("UPDATE message_views_state SET to_bch_name=? WHERE to_bch_name=?")) {
ps.setString(1, newBlockchainName);
ps.setString(2, oldBlockchainName);
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("UPDATE channel_read_state SET owner_bch_name=? WHERE owner_bch_name=?")) {
ps.setString(1, newBlockchainName);
ps.setString(2, oldBlockchainName);
ps.executeUpdate();
}
ChainRef ref=chainRef(blockchainName);
try(Connection c=db.getConnection()){
boolean ac=c.getAutoCommit(); c.setAutoCommit(false);
try{
// Targets liked by this actor must survive long enough to recount after actor rows are removed.
try(Statement st=c.createStatement()){
st.execute("CREATE TEMP TABLE IF NOT EXISTS shine_cleanup_targets(to_login TEXT,to_block_number INTEGER,to_block_hash BYTEA,PRIMARY KEY(to_login,to_block_number,to_block_hash)) ON COMMIT DROP");
st.execute("TRUNCATE shine_cleanup_targets");
st.execute("CREATE TEMP TABLE IF NOT EXISTS shine_cleanup_channels(owner_bch_name TEXT,root_number INTEGER,root_hash BYTEA,PRIMARY KEY(owner_bch_name,root_number,root_hash)) ON COMMIT DROP");
st.execute("TRUNCATE shine_cleanup_channels");
st.execute("CREATE TEMP TABLE IF NOT EXISTS shine_cleanup_users(login TEXT PRIMARY KEY) ON COMMIT DROP");
st.execute("TRUNCATE shine_cleanup_users");
}
rebuildInboundMessageCounters(c, login, newBlockchainName);
rebuildStatsState(c);
c.commit();
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to refresh logical target blockchain cache for login=" + login, e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
try(PreparedStatement ps=c.prepareStatement("""
INSERT INTO shine_cleanup_targets(to_login,to_block_number,to_block_hash)
SELECT DISTINCT to_login,to_block_number,to_block_hash FROM reactions_state
WHERE LOWER(from_login)=LOWER(?) AND reaction_type=1 AND last_sub_type=1
ON CONFLICT DO NOTHING
""")){ ps.setString(1,ref.login()); ps.executeUpdate(); }
/**
* После replay candidate-цепочки её собственные message_stats уже существуют, но входящие реакции/ответы
* других пользователей были созданы раньше и не проходили триггеры повторно. Поэтому пересчитываем
* агрегаты из исходных таблиц после перепривязки runtime to_bch_name cache.
*/
private void rebuildInboundMessageCounters(Connection c, String login, String newBlockchainName) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
UPDATE message_stats ms
SET likes_count = (
SELECT COUNT(*)::INTEGER
FROM reactions_state rs
WHERE rs.reaction_type = ?
AND rs.last_sub_type = ?
AND LOWER(rs.to_login) = LOWER(ms.to_login)
AND rs.to_bch_name = ms.to_bch_name
AND rs.to_block_number = ms.to_block_number
AND rs.to_block_hash = ms.to_block_hash
),
replies_count = (
SELECT COUNT(*)::INTEGER
FROM blocks b
WHERE b.msg_type = 1
AND b.msg_sub_type = ?
AND LOWER(b.to_login) = LOWER(ms.to_login)
AND b.to_bch_name = ms.to_bch_name
AND b.to_block_number = ms.to_block_number
AND b.to_block_hash = ms.to_block_hash
)
WHERE LOWER(ms.to_login) = LOWER(?)
AND ms.to_bch_name = ?
""")) {
ps.setInt(1, DatabaseInitializer.REACTION_LIKE);
ps.setInt(2, DatabaseInitializer.REACTION_LIKE);
ps.setInt(3, DatabaseInitializer.TEXT_REPLY);
ps.setString(4, login);
ps.setString(5, newBlockchainName);
ps.executeUpdate();
}
}
try(PreparedStatement ps=c.prepareStatement("""
INSERT INTO shine_cleanup_users(login)
SELECT DISTINCT to_login FROM connections_state
WHERE LOWER(login)=LOWER(?) AND to_login IS NOT NULL AND btrim(to_login)<>''
ON CONFLICT DO NOTHING
""")){ps.setString(1,ref.login());ps.executeUpdate();}
private void updateTargetCacheByLogin(Connection c, String table, String login, String newBlockchainName) throws SQLException {
String sql = "UPDATE " + table + " SET to_bch_name=? WHERE LOWER(to_login)=LOWER(?) AND to_bch_name IS DISTINCT FROM ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, newBlockchainName);
ps.setString(2, login);
ps.setString(3, newBlockchainName);
ps.executeUpdate();
}
}
try(PreparedStatement ps=c.prepareStatement("""
INSERT INTO shine_cleanup_channels(owner_bch_name,root_number,root_hash)
SELECT DISTINCT cn.owner_bch_name,cn.channel_root_block_number,cn.channel_root_block_hash
FROM connections_state cs JOIN channel_names_state cn
ON LOWER(cn.owner_login)=LOWER(cs.to_login) AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30
ON CONFLICT DO NOTHING
""")){ps.setString(1,ref.login());ps.executeUpdate();}
// Followers of channels owned by this login must be refreshed after those channel projections are removed.
try(PreparedStatement ps=c.prepareStatement("""
INSERT INTO shine_cleanup_users(login)
SELECT DISTINCT cs.login
FROM connections_state cs
JOIN channel_names_state cn
ON LOWER(cn.owner_login)=LOWER(?)
AND LOWER(cs.to_login)=LOWER(cn.owner_login)
AND cs.to_block_number=cn.channel_root_block_number
AND cs.to_block_hash=cn.channel_root_block_hash
WHERE cs.rel_type=30
ON CONFLICT DO NOTHING
""")){ps.setString(1,ref.login());ps.executeUpdate();}
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
String sql = """
SELECT login
FROM blockchain_state
WHERE blockchain_name = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, blockchainName);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
String login = rs.getString("login");
if (login != null && !login.isBlank()) {
return login;
}
int deletedReactions=delete(c,"DELETE FROM reactions_state WHERE LOWER(from_login)=LOWER(?)",ref.login());
recountAffectedLikes(c);
int deletedConnections=delete(c,"DELETE FROM connections_state WHERE LOWER(login)=LOWER(?)",ref.login());
refreshAffectedChannelStats(c);
int deletedUsersParams=delete(c,"DELETE FROM users_params WHERE LOWER(login)=LOWER(?)",ref.login());
try(PreparedStatement ps=c.prepareStatement("SELECT shine_refresh_user_profile(?)")){ps.setString(1,ref.login());ps.execute();}
try(PreparedStatement ps=c.prepareStatement("SELECT shine_refresh_related_user_stats(?)")){ps.setString(1,ref.login());ps.execute();}
// Channel/chat projections are owned by login; replay recreates them. Incoming social stats are intentionally untouched.
delete(c,"DELETE FROM channel_stats_state WHERE LOWER(owner_login)=LOWER(?)",ref.login());
int deletedChannelNames=delete(c,"DELETE FROM channel_names_state WHERE LOWER(owner_login)=LOWER(?)",ref.login());
refreshAffectedUserStats(c);
int deletedChat200=delete(c,"DELETE FROM chat200_state WHERE LOWER(owner_login)=LOWER(?)",ref.login());
int deletedChatMembers=delete(c,"DELETE FROM chat200_members_state WHERE owner_bch_name LIKE ?",ref.login()+"-%");
try(PreparedStatement ps=c.prepareStatement("SELECT shine_refresh_user_stats(?)")){ps.setString(1,ref.login());ps.execute();}
int deletedBlocks;
try(PreparedStatement ps=c.prepareStatement("DELETE FROM blocks_store WHERE LOWER(login)=LOWER(?) AND fork_number=?")){
ps.setString(1,ref.login()); ps.setInt(2,ref.fork()); deletedBlocks=ps.executeUpdate();
}
}
}
String loginFromName = loginFromBlockchainName(blockchainName);
if (loginFromName == null || loginFromName.isBlank()) {
throw new IllegalArgumentException("Cannot derive login from blockchainName: " + blockchainName);
}
return loginFromName;
}
/**
* DAO остаётся в модуле БД и не тянет зависимость на blockchain-utils модуль.
* Поэтому здесь локально повторяем минимальное правило имени chain:
* login + "-NNN".
*/
private String loginFromBlockchainName(String blockchainName) {
if (blockchainName == null) return null;
String s = blockchainName.trim();
if (s.length() <= BLOCKCHAIN_LOGIN_SUFFIX_LEN) return null;
int dashPos = s.length() - BLOCKCHAIN_LOGIN_SUFFIX_LEN;
if (s.charAt(dashPos) != '-') return null;
for (int i = dashPos + 1; i < s.length(); i++) {
char ch = s.charAt(i);
if (ch < '0' || ch > '9') return null;
}
return s.substring(0, dashPos);
}
/**
* Уменьшаем likes_count только для ЧУЖИХ целей.
*
* Логика:
* - если у удаляемой цепочки финальное состояние реакции на цель = LIKE,
* значит при полном удалении цепочки этот активный лайк исчезает;
* - значит у message_stats этой чужой цели нужно сделать -1;
* - для целей внутри этой же chain этого делать не нужно, потому что сами цели
* тоже будут удалены вместе с цепочкой.
*/
private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException {
String sql = """
UPDATE message_stats
SET likes_count = GREATEST(
0,
likes_count - COALESCE((
SELECT COUNT(*)::int
FROM reactions_state rs
WHERE rs.from_bch_name = ?
AND rs.reaction_type = ?
AND rs.last_sub_type = ?
AND rs.to_login = message_stats.to_login
AND rs.to_bch_name = message_stats.to_bch_name
AND rs.to_block_number = message_stats.to_block_number
AND rs.to_block_hash = message_stats.to_block_hash
AND rs.to_bch_name <> ?
), 0)
)
WHERE EXISTS (
SELECT 1
FROM reactions_state rs
WHERE rs.from_bch_name = ?
AND rs.reaction_type = ?
AND rs.last_sub_type = ?
AND rs.to_login = message_stats.to_login
AND rs.to_bch_name = message_stats.to_bch_name
AND rs.to_block_number = message_stats.to_block_number
AND rs.to_block_hash = message_stats.to_block_hash
AND rs.to_bch_name <> ?
)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
ps.setString(i++, blockchainName);
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
ps.setString(i++, blockchainName);
ps.setString(i++, blockchainName);
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
ps.setInt(i++, DatabaseInitializer.REACTION_LIKE);
ps.setString(i++, blockchainName);
return ps.executeUpdate();
int deletedState;
try(PreparedStatement ps=c.prepareStatement("DELETE FROM blockchain_state_store WHERE LOWER(login)=LOWER(?) AND fork_number=?")){
ps.setString(1,ref.login()); ps.setInt(2,ref.fork()); deletedState=ps.executeUpdate();
}
c.commit();
return new CleanupResult(ref.login(),deletedReactions,deletedConnections,deletedUsersParams,deletedChannelNames,deletedChat200,deletedChatMembers,deletedBlocks,deletedState);
}catch(Exception e){ try{c.rollback();}catch(Exception ignored){} if(e instanceof SQLException x)throw x; throw new SQLException("Stage2 fork cleanup failed: "+blockchainName,e); }
finally{try{c.setAutoCommit(ac);}catch(Exception ignored){}}
}
}
/**
* Уменьшаем replies_count только для ЧУЖИХ целей.
*
* Если reply этой цепочки ссылался на сообщение из другой цепочки,
* значит после удаления blocks этой цепочки чужой replies_count должен уменьшиться.
* Reply на собственные сообщения здесь игнорируем: целевая цепочка тоже будет удалена.
*/
private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException {
String sql = """
UPDATE message_stats
SET replies_count = GREATEST(
0,
replies_count - COALESCE((
SELECT COUNT(*)::int
FROM blocks b
WHERE b.bch_name = ?
AND b.msg_type = 1
AND b.msg_sub_type = ?
AND b.to_login = message_stats.to_login
AND b.to_bch_name = message_stats.to_bch_name
AND b.to_block_number = message_stats.to_block_number
AND b.to_block_hash = message_stats.to_block_hash
AND b.to_bch_name <> ?
), 0)
)
WHERE EXISTS (
SELECT 1
FROM blocks b
WHERE b.bch_name = ?
AND b.msg_type = 1
AND b.msg_sub_type = ?
AND b.to_login = message_stats.to_login
AND b.to_bch_name = message_stats.to_bch_name
AND b.to_block_number = message_stats.to_block_number
AND b.to_block_hash = message_stats.to_block_hash
AND b.to_bch_name <> ?
)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
ps.setString(i++, blockchainName);
ps.setInt(i++, DatabaseInitializer.TEXT_REPLY);
ps.setString(i++, blockchainName);
ps.setString(i++, blockchainName);
ps.setInt(i++, DatabaseInitializer.TEXT_REPLY);
ps.setString(i++, blockchainName);
return ps.executeUpdate();
}
}
/**
* Статистика сообщений самой удаляемой цепочки после reset не нужна,
* потому что её цели исчезают вместе с chain source data.
*/
private int deleteMessageStatsForOwnTargets(Connection c, String blockchainName) throws SQLException {
return executeDelete(c, """
DELETE FROM message_stats
WHERE to_bch_name = ?
""", blockchainName);
}
/**
* reactions_state хранит финальное состояние реакций АКТОРА.
* После удаления всей цепочки актор этой цепочки исчезает, поэтому
* достаточно удалить все строки по from_bch_name.
*/
private int deleteReactionsStateForActorChain(Connection c, String blockchainName) throws SQLException {
return executeDelete(c, """
DELETE FROM reactions_state
WHERE from_bch_name = ?
""", blockchainName);
}
/**
* connections_state — текущее состояние связей, выставленных этим login.
* Чистим по владельцу состояния.
*/
private int deleteConnectionsStateForLogin(Connection c, String login) throws SQLException {
return executeDelete(c, """
DELETE FROM connections_state
WHERE LOWER(login) = LOWER(?)
""", login);
}
/**
* users_params — актуальные параметры, собранные из блоков пользователя.
*/
private int deleteUsersParamsForLogin(Connection c, String login) throws SQLException {
return executeDelete(c, """
DELETE FROM users_params
WHERE LOWER(login) = LOWER(?)
""", login);
}
/**
* Каналы принадлежат owner_bch_name.
*/
private int deleteChannelNamesForOwnerChain(Connection c, String blockchainName) throws SQLException {
return executeDelete(c, """
DELETE FROM channel_names_state
WHERE owner_bch_name = ?
""", blockchainName);
}
private int deleteChat200StateForOwnerChain(Connection c, String blockchainName) throws SQLException {
return executeDelete(c, """
DELETE FROM chat200_state
WHERE owner_bch_name = ?
""", blockchainName);
}
private int deleteChat200MembersForOwnerChain(Connection c, String blockchainName) throws SQLException {
return executeDelete(c, """
DELETE FROM chat200_members_state
WHERE owner_bch_name = ?
""", blockchainName);
}
/**
* blocks удаляем в конце, потому что до этого шага они нужны как источник правды
* для уменьшения replies_count.
*/
private int deleteBlocksForChain(Connection c, String blockchainName) throws SQLException {
return executeDelete(c, """
DELETE FROM blocks
WHERE bch_name = ?
""", blockchainName);
}
/**
* blockchain_state удаляем после blocks, чтобы не нарушать FK-связь blocks -> blockchain_state.
*/
private int deleteBlockchainStateForChain(Connection c, String blockchainName) throws SQLException {
return executeDelete(c, """
DELETE FROM blockchain_state
WHERE blockchain_name = ?
""", blockchainName);
}
private void rebuildStatsState(Connection c) throws SQLException {
try (PreparedStatement truncate = c.prepareStatement("""
TRUNCATE TABLE user_stats_state, channel_stats_state
""")) {
truncate.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO user_stats_state (
login,
owned_public_channels_count,
following_users_count,
following_channels_count,
close_friends_count,
updated_at_ms
)
SELECT
u.login,
COALESCE(own.owned_public_channels_count, 0),
COALESCE(fu.following_users_count, 0),
COALESCE(fc.following_channels_count, 0),
COALESCE(cf.close_friends_count, 0),
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM solana_user_pda_current u
LEFT JOIN (
SELECT owner_login, COUNT(*)::INTEGER AS owned_public_channels_count
FROM channel_names_state
WHERE channel_type_code = 1
GROUP BY owner_login
) own ON LOWER(own.owner_login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS following_users_count
FROM connections_state
WHERE rel_type = 30
AND to_block_number = 0
GROUP BY login
) fu ON LOWER(fu.login) = LOWER(u.login)
LEFT JOIN (
SELECT cs.login, COUNT(*)::INTEGER AS following_channels_count
FROM connections_state cs
JOIN channel_names_state cn
ON cn.owner_bch_name = cs.to_bch_name
AND cn.channel_root_block_number = cs.to_block_number
AND cn.channel_root_block_hash = cs.to_block_hash
WHERE cs.rel_type = 30
AND cn.channel_type_code = 1
GROUP BY cs.login
) fc ON LOWER(fc.login) = LOWER(u.login)
LEFT JOIN (
SELECT login, COUNT(*)::INTEGER AS close_friends_count
FROM connections_state
WHERE rel_type = 10
GROUP BY login
) cf ON LOWER(cf.login) = LOWER(u.login)
""")) {
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_profile(login) FROM solana_user_pda_current")) {
ps.execute();
}
try (PreparedStatement ps = c.prepareStatement("SELECT shine_refresh_user_stats(login) FROM solana_user_pda_current")) {
ps.execute();
}
try (PreparedStatement ps = c.prepareStatement("""
private void recountAffectedLikes(Connection c)throws SQLException{
try(PreparedStatement ps=c.prepareStatement("""
UPDATE message_stats ms SET
primary_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
AND shine_is_primary(rs.from_login)),
shining_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login))
""")) {
ps.executeUpdate();
}
likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1 AND rs.to_login=ms.to_login AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash),
primary_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1 AND rs.to_login=ms.to_login AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash AND shine_is_primary(rs.from_login)),
shining_likes_count=(SELECT COUNT(*)::INTEGER FROM reactions_state rs WHERE rs.reaction_type=1 AND rs.last_sub_type=1 AND rs.to_login=ms.to_login AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login))
WHERE EXISTS(SELECT 1 FROM shine_cleanup_targets t WHERE t.to_login=ms.to_login AND t.to_block_number=ms.to_block_number AND t.to_block_hash=ms.to_block_hash)
""")){ps.executeUpdate();}
}
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO channel_stats_state (
owner_bch_name,
channel_root_block_number,
channel_root_block_hash,
owner_login,
channel_type_code,
subscribers_count,
updated_at_ms
)
SELECT
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code,
COUNT(DISTINCT cs.login)::INTEGER AS subscribers_count,
CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
FROM channel_names_state cn
LEFT JOIN connections_state cs
ON cs.rel_type = 30
AND cs.to_bch_name = cn.owner_bch_name
AND cs.to_block_number = cn.channel_root_block_number
AND cs.to_block_hash = cn.channel_root_block_hash
WHERE cn.channel_type_code = 1
GROUP BY
cn.owner_bch_name,
cn.channel_root_block_number,
cn.channel_root_block_hash,
cn.owner_login,
cn.channel_type_code
""")) {
ps.executeUpdate();
private void refreshAffectedChannelStats(Connection c)throws SQLException{
try(PreparedStatement q=c.prepareStatement("SELECT owner_bch_name,root_number,root_hash FROM shine_cleanup_channels"); ResultSet rs=q.executeQuery(); PreparedStatement p=c.prepareStatement("SELECT shine_refresh_channel_follow_stats(?,?,?)")){
while(rs.next()){p.setString(1,rs.getString(1));p.setInt(2,rs.getInt(2));p.setBytes(3,rs.getBytes(3));p.execute();}
}
}
private int executeDelete(Connection c, String sql, String value) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, value);
return ps.executeUpdate();
private void refreshAffectedUserStats(Connection c)throws SQLException{
try(PreparedStatement q=c.prepareStatement("SELECT login FROM shine_cleanup_users"); ResultSet rs=q.executeQuery(); PreparedStatement p=c.prepareStatement("SELECT shine_refresh_user_stats(?)")){
while(rs.next()){p.setString(1,rs.getString(1));p.execute();}
}
}
/**
* Технический результат cleanup-операции.
* Нужен для будущего логирования и ручной диагностики resync-flow.
*/
public record CleanupResult(
String login,
int likesAdjustedRows,
int repliesAdjustedRows,
int deletedMessageStatsRows,
int deletedReactionsStateRows,
int deletedConnectionsStateRows,
int deletedUsersParamsRows,
int deletedChannelNamesRows,
int deletedChat200StateRows,
int deletedChat200MembersRows,
int deletedBlocksRows,
int deletedBlockchainStateRows
) {}
private int delete(Connection c,String sql,String v)throws SQLException{try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,v);return ps.executeUpdate();}}
private record ChainRef(String login,int fork){}
private static ChainRef chainRef(String n){ if(n==null)throw new IllegalArgumentException("blockchainName is null"); String s=n.trim();int d=s.lastIndexOf('-');if(d<=0||s.length()-d-1!=3)throw new IllegalArgumentException("Bad blockchainName: "+n);try{int f=Integer.parseInt(s.substring(d+1));if(f<1||f>999)throw new IllegalArgumentException("fork out of range");return new ChainRef(s.substring(0,d),f);}catch(NumberFormatException e){throw new IllegalArgumentException("Bad blockchainName: "+n,e);} }
public record CleanupResult(String login,int deletedReactionsStateRows,int deletedConnectionsStateRows,int deletedUsersParamsRows,int deletedChannelNamesRows,int deletedChat200StateRows,int deletedChat200MembersRows,int deletedBlocksRows,int deletedBlockchainStateRows){}
}
@@ -34,8 +34,8 @@ public final class BlockchainStateDAO {
public BlockchainStateEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
String sql = """
SELECT
blockchain_name,
login,
fork_number,
blockchain_key,
size_limit,
file_size_bytes,
@@ -43,11 +43,13 @@ public final class BlockchainStateDAO {
last_block_hash,
updated_at_ms
FROM blockchain_state
WHERE blockchain_name = ?
WHERE login = ? AND fork_number = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, blockchainName);
ChainRef r=chainRef(blockchainName);
ps.setString(1, r.login());
ps.setInt(2, r.fork());
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
@@ -66,8 +68,8 @@ public final class BlockchainStateDAO {
public List<BlockchainStateEntry> listAll(Connection c) throws SQLException {
String sql = """
SELECT
blockchain_name,
login,
fork_number,
blockchain_key,
size_limit,
file_size_bytes,
@@ -75,7 +77,7 @@ public final class BlockchainStateDAO {
last_block_hash,
updated_at_ms
FROM blockchain_state
ORDER BY LOWER(blockchain_name)
ORDER BY LOWER(login), fork_number
""";
List<BlockchainStateEntry> result = new ArrayList<>();
@@ -98,9 +100,9 @@ public final class BlockchainStateDAO {
/** UPSERT с внешним соединением. Соединение НЕ закрывает. */
public void upsert(Connection c, BlockchainStateEntry e) throws SQLException {
String sql = """
INSERT INTO blockchain_state (
blockchain_name,
INSERT INTO blockchain_state_store (
login,
fork_number,
blockchain_key,
size_limit,
file_size_bytes,
@@ -108,9 +110,8 @@ public final class BlockchainStateDAO {
last_block_hash,
updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(blockchain_name)
ON CONFLICT(login, fork_number)
DO UPDATE SET
login = excluded.login,
blockchain_key = excluded.blockchain_key,
size_limit = excluded.size_limit,
file_size_bytes = excluded.file_size_bytes,
@@ -122,8 +123,8 @@ public final class BlockchainStateDAO {
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
ps.setString(i++, e.getBlockchainName());
ps.setString(i++, nn(e.getLogin()));
ps.setInt(i++, e.getForkNumber());
ps.setString(i++, nn(e.getBlockchainKey()));
ps.setLong(i++, e.getSizeLimit());
@@ -148,9 +149,9 @@ public final class BlockchainStateDAO {
*/
public boolean insertIfMissing(Connection c, BlockchainStateEntry e) throws SQLException {
String sql = """
INSERT INTO blockchain_state (
blockchain_name,
INSERT INTO blockchain_state_store (
login,
fork_number,
blockchain_key,
size_limit,
file_size_bytes,
@@ -158,14 +159,14 @@ public final class BlockchainStateDAO {
last_block_hash,
updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(blockchain_name) DO NOTHING
ON CONFLICT(login, fork_number) DO NOTHING
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
ps.setString(i++, e.getBlockchainName());
ps.setString(i++, nn(e.getLogin()));
ps.setInt(i++, e.getForkNumber());
ps.setString(i++, nn(e.getBlockchainKey()));
ps.setLong(i++, e.getSizeLimit());
@@ -191,20 +192,22 @@ public final class BlockchainStateDAO {
*/
public boolean tryIncreaseFileSizeWithinLimit(Connection c, String blockchainName, long deltaBytes, long nowMs) throws SQLException {
String sql = """
UPDATE blockchain_state
UPDATE blockchain_state_store
SET
file_size_bytes = file_size_bytes + ?,
updated_at_ms = ?
WHERE
blockchain_name = ?
login = ? AND fork_number = ?
AND (file_size_bytes + ?) <= size_limit
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, deltaBytes);
ps.setLong(2, nowMs);
ps.setString(3, blockchainName);
ps.setLong(4, deltaBytes);
ChainRef r=chainRef(blockchainName);
ps.setString(3, r.login());
ps.setInt(4, r.fork());
ps.setLong(5, deltaBytes);
return ps.executeUpdate() > 0;
}
}
@@ -212,8 +215,8 @@ public final class BlockchainStateDAO {
private BlockchainStateEntry mapRow(ResultSet rs) throws SQLException {
BlockchainStateEntry e = new BlockchainStateEntry();
e.setBlockchainName(rs.getString("blockchain_name"));
e.setLogin(rs.getString("login"));
e.setForkNumber(rs.getInt("fork_number"));
e.setBlockchainKey(rs.getString("blockchain_key"));
e.setSizeLimit(rs.getLong("size_limit"));
@@ -227,6 +230,15 @@ public final class BlockchainStateDAO {
return e;
}
private record ChainRef(String login,int fork) {}
private static ChainRef chainRef(String name) {
if(name==null) throw new IllegalArgumentException("blockchainName is null");
String s=name.trim(); int d=s.lastIndexOf('-');
if(d<=0 || s.length()-d-1 != 3) throw new IllegalArgumentException("Bad blockchainName: "+name);
try { int f=Integer.parseInt(s.substring(d+1)); if(f<1||f>999) throw new IllegalArgumentException("fork out of range"); return new ChainRef(s.substring(0,d),f); }
catch(NumberFormatException e){ throw new IllegalArgumentException("Bad blockchainName: "+name,e); }
}
private static void setBytesNullable(PreparedStatement ps, int index, byte[] b) throws SQLException {
if (b != null) ps.setBytes(index, b);
else ps.setNull(index, Types.BINARY);
@@ -23,9 +23,9 @@ public final class BlocksDAO {
public void insert(Connection c, BlockEntry e) throws SQLException {
String sql = """
INSERT INTO blocks (
login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_bch_name,to_block_number,to_block_hash,
INSERT INTO blocks_store (
login,fork_number,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_fork_number,to_block_number,to_block_hash,
block_hash,block_signature,data_item_id,
arweave_publish_pending,arweave_published_at_ms,
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
@@ -34,13 +34,13 @@ public final class BlocksDAO {
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
ps.setString(i++, e.getLogin());
ps.setString(i++, e.getBchName());
ps.setInt(i++, e.getForkNumber());
ps.setInt(i++, e.getBlockNumber());
ps.setInt(i++, e.getMsgType());
ps.setInt(i++, e.getMsgSubType());
ps.setBytes(i++, e.getBlockBytes());
setNullableString(ps, i++, e.getToLogin());
setNullableString(ps, i++, e.getToBchName());
setNullableInt(ps, i++, e.getToForkNumber());
setNullableInt(ps, i++, e.getToBlockNumber());
setNullableBytes(ps, i++, e.getToBlockHash());
ps.setBytes(i++, e.getBlockHash());
@@ -71,13 +71,13 @@ public final class BlocksDAO {
public List<BlockEntry> listPendingArweave(int limit) throws SQLException {
int safeLimit = Math.max(1, Math.min(limit, 100_000));
String sql = """
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
SELECT login,fork_number,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_fork_number,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
arweave_publish_pending,arweave_published_at_ms,
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
FROM blocks
WHERE arweave_publish_pending = TRUE
ORDER BY bch_name ASC, block_number ASC
ORDER BY login ASC, fork_number ASC, block_number ASC
LIMIT ?
""";
List<BlockEntry> out = new ArrayList<>();
@@ -91,7 +91,7 @@ public final class BlocksDAO {
public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
if (dataItemIds == null || dataItemIds.isEmpty()) return;
String sql = """
UPDATE blocks
UPDATE blocks_store
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?
WHERE data_item_id=?
""";
@@ -108,30 +108,32 @@ public final class BlocksDAO {
}
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("SELECT block_hash FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1")) {
ps.setString(1,bchName); ps.setInt(2,blockNumber);
try (PreparedStatement ps = c.prepareStatement("SELECT block_hash FROM blocks WHERE login=? AND fork_number=? AND block_number=? LIMIT 1")) {
ChainRef r=chainRef(bchName); ps.setString(1,r.login()); ps.setInt(2,r.fork()); ps.setInt(3,blockNumber);
try (ResultSet rs=ps.executeQuery()) { return rs.next()?rs.getBytes(1):null; }
}
}
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { try(Connection c=db.getConnection()){return getHashByNumber(c,bchName,blockNumber);} }
public BlockEntry getByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
String sql = baseSelect()+" WHERE bch_name=? AND block_number=? LIMIT 1";
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,bchName); ps.setInt(2,blockNumber); try(ResultSet rs=ps.executeQuery()){return rs.next()?mapRow(rs):null;} }
String sql = baseSelect()+" WHERE login=? AND fork_number=? AND block_number=? LIMIT 1";
ChainRef r=chainRef(bchName);
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,r.login()); ps.setInt(2,r.fork()); ps.setInt(3,blockNumber); try(ResultSet rs=ps.executeQuery()){return rs.next()?mapRow(rs):null;} }
}
public BlockEntry getByNumber(String bchName,int blockNumber)throws SQLException{try(Connection c=db.getConnection()){return getByNumber(c,bchName,blockNumber);} }
public List<BlockEntry> listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} }
public List<BlockEntry> listRangeByNumber(Connection c,String bchName,int from,int to)throws SQLException{
String sql=baseSelect()+" WHERE bch_name=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC";
String sql=baseSelect()+" WHERE login=? AND fork_number=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC";
List<BlockEntry> out=new ArrayList<>();
try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,bchName);ps.setInt(2,from);ps.setInt(3,to);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(mapRow(rs));}}
ChainRef r=chainRef(bchName);
try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,r.login());ps.setInt(2,r.fork());ps.setInt(3,from);ps.setInt(4,to);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(mapRow(rs));}}
return out;
}
private static String baseSelect(){return """
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
SELECT login,fork_number,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_fork_number,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
arweave_publish_pending,arweave_published_at_ms,
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
FROM blocks
@@ -139,15 +141,27 @@ public final class BlocksDAO {
private BlockEntry mapRow(ResultSet rs)throws SQLException{
BlockEntry e=new BlockEntry();
e.setLogin(rs.getString("login")); e.setBchName(rs.getString("bch_name")); e.setBlockNumber(rs.getInt("block_number"));
e.setLogin(rs.getString("login")); e.setForkNumber(rs.getInt("fork_number")); e.setBlockNumber(rs.getInt("block_number"));
e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes"));
e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash"));
e.setToLogin(rs.getString("to_login")); e.setToForkNumber((Integer)rs.getObject("to_fork_number")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash"));
e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id"));
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms"));
e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number"));
return e;
}
private record ChainRef(String login, int fork) {}
private static ChainRef chainRef(String name) {
if (name == null) throw new IllegalArgumentException("blockchainName is null");
String s=name.trim(); int dash=s.lastIndexOf('-');
if (dash <= 0 || s.length()-dash-1 != 3) throw new IllegalArgumentException("Bad blockchainName: "+name);
try {
int fork=Integer.parseInt(s.substring(dash+1));
if (fork < 1 || fork > 999) throw new IllegalArgumentException("fork out of range: "+name);
return new ChainRef(s.substring(0,dash),fork);
} catch(NumberFormatException ex){ throw new IllegalArgumentException("Bad blockchainName: "+name,ex); }
}
private static void setNullableString(PreparedStatement ps,int i,String v)throws SQLException{if(v==null)ps.setNull(i,Types.VARCHAR);else ps.setString(i,v);}
private static void setNullableInt(PreparedStatement ps,int i,Integer v)throws SQLException{if(v==null)ps.setNull(i,Types.INTEGER);else ps.setInt(i,v);}
private static void setNullableBytes(PreparedStatement ps,int i,byte[] v)throws SQLException{if(v==null)ps.setNull(i,Types.BINARY);else ps.setBytes(i,v);}
@@ -39,12 +39,12 @@ public final class ChannelReadStateDAO {
String sql = """
SELECT read_count, unread_count, read_updated_at_ms, client_key, read_signature
FROM channel_read_state
WHERE LOWER(viewer_login)=LOWER(?) AND owner_bch_name=? AND channel_name=?
WHERE LOWER(viewer_login)=LOWER(?) AND LOWER(owner_login)=LOWER(?) AND channel_name=?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerBchName);
ps.setString(2, loginFromChainName(ownerBchName));
ps.setString(3, channelName);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
@@ -74,10 +74,10 @@ public final class ChannelReadStateDAO {
JOIN connections_state cs
ON LOWER(cs.login)=LOWER(crs.viewer_login)
AND cs.rel_type=30
AND cs.to_bch_name=cns.owner_bch_name
AND LOWER(cs.to_login)=LOWER(cns.owner_login)
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
AND cs.to_block_hash=cns.channel_root_block_hash
WHERE cns.owner_bch_name=crs.owner_bch_name
WHERE LOWER(cns.owner_login)=LOWER(crs.owner_login)
AND cns.slug=crs.channel_name
AND cns.channel_type_code=1
)
@@ -101,7 +101,7 @@ public final class ChannelReadStateDAO {
JOIN connections_state cs
ON LOWER(cs.login)=LOWER(?)
AND cs.rel_type=?
AND cs.to_bch_name=cns.owner_bch_name
AND LOWER(cs.to_login)=LOWER(cns.owner_login)
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
AND cs.to_block_hash=cns.channel_root_block_hash
WHERE cns.owner_bch_name=?
@@ -189,9 +189,10 @@ public final class ChannelReadStateDAO {
read_count, unread_count,
read_updated_at_ms, client_key, read_signature, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
ON CONFLICT (viewer_login, owner_bch_name, channel_name)
ON CONFLICT (viewer_login, owner_login, channel_name)
DO UPDATE SET
owner_login = EXCLUDED.owner_login,
owner_bch_name = EXCLUDED.owner_bch_name,
unread_count = GREATEST(
0,
channel_read_state.unread_count -
@@ -244,7 +245,7 @@ public final class ChannelReadStateDAO {
UPDATE channel_read_state crs
SET unread_count = crs.unread_count + 1,
updated_at_ms = ?
WHERE crs.owner_bch_name = ?
WHERE LOWER(crs.owner_login)=LOWER(?)
AND crs.channel_name = ?
AND LOWER(crs.viewer_login) <> LOWER(crs.owner_login)
AND EXISTS (
@@ -253,10 +254,10 @@ public final class ChannelReadStateDAO {
JOIN connections_state cs
ON LOWER(cs.login)=LOWER(crs.viewer_login)
AND cs.rel_type=?
AND cs.to_bch_name=cns.owner_bch_name
AND LOWER(cs.to_login)=LOWER(cns.owner_login)
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
AND cs.to_block_hash=cns.channel_root_block_hash
WHERE cns.owner_bch_name=crs.owner_bch_name
WHERE LOWER(cns.owner_login)=LOWER(crs.owner_login)
AND cns.slug=crs.channel_name
AND cns.channel_type_code=1
)
@@ -265,7 +266,7 @@ public final class ChannelReadStateDAO {
List<String> affected = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, nowMs);
ps.setString(2, ownerBchName);
ps.setString(2, loginFromChainName(ownerBchName));
ps.setString(3, channelName);
ps.setInt(4, followRelType);
try (ResultSet rs = ps.executeQuery()) {
@@ -282,17 +283,19 @@ public final class ChannelReadStateDAO {
DELETE FROM channel_read_state crs
USING channel_names_state cns
WHERE LOWER(crs.viewer_login)=LOWER(?)
AND crs.owner_bch_name=?
AND cns.owner_bch_name=crs.owner_bch_name
AND LOWER(crs.owner_login)=LOWER(?)
AND LOWER(cns.owner_login)=LOWER(crs.owner_login)
AND cns.slug=crs.channel_name
AND cns.channel_root_block_number=?
AND cns.channel_type_code=1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerBchName);
ps.setString(2, loginFromChainName(ownerBchName));
ps.setInt(3, rootBlockNumber);
return ps.executeUpdate() > 0;
}
}
private static String loginFromChainName(String bch){ if(bch==null)return null; int d=bch.lastIndexOf('-'); return d>0?bch.substring(0,d):bch; }
}
@@ -39,19 +39,14 @@ public final class ConnectionsStateDAO {
*/
public List<String> listOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
String sql = """
SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login
SELECT COALESCE(u_login.login, cs.to_login) AS friend_login
FROM connections_state cs
LEFT JOIN %s
ON LOWER(u_login.login) = LOWER(cs.to_login)
LEFT JOIN %s
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
WHERE LOWER(cs.login) = LOWER(?)
AND cs.rel_type = ?
ORDER BY friend_login
""".formatted(
CurrentUsersSql.usersSubquery("u_login"),
CurrentUsersSql.usersSubquery("u_bch")
);
""".formatted(CurrentUsersSql.usersSubquery("u_login"));
List<String> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -73,19 +68,14 @@ public final class ConnectionsStateDAO {
FROM connections_state cs
LEFT JOIN %s
ON LOWER(u_login.login) = LOWER(cs.to_login)
LEFT JOIN %s
ON LOWER(u_bch.blockchain_name) = LOWER(cs.to_bch_name)
WHERE LOWER(cs.login) = LOWER(?)
AND cs.rel_type = ?
AND (
LOWER(cs.to_login) = LOWER(?)
OR LOWER(COALESCE(u_login.login, u_bch.login, cs.to_login)) = LOWER(?)
OR LOWER(COALESCE(u_login.login, cs.to_login)) = LOWER(?)
)
LIMIT 1
""".formatted(
CurrentUsersSql.usersSubquery("u_login"),
CurrentUsersSql.usersSubquery("u_bch")
);
""".formatted(CurrentUsersSql.usersSubquery("u_login"));
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, loginAnyCase);
ps.setInt(2, relType);
@@ -104,33 +94,12 @@ public final class ConnectionsStateDAO {
String sql = """
SELECT COALESCE(u_actor.login, cs.login) AS friend_login
FROM connections_state cs
LEFT JOIN %s
ON LOWER(u_actor.login) = LOWER(cs.login)
LEFT JOIN %s
ON LOWER(u_target.login) = LOWER(?)
WHERE (
LOWER(cs.to_login) = LOWER(?)
OR (u_target.blockchain_name IS NOT NULL AND LOWER(cs.to_bch_name) = LOWER(u_target.blockchain_name))
)
AND cs.rel_type = ?
LEFT JOIN %s ON LOWER(u_actor.login)=LOWER(cs.login)
WHERE LOWER(cs.to_login)=LOWER(?) AND cs.rel_type=?
ORDER BY friend_login
""".formatted(
CurrentUsersSql.usersSubquery("u_actor"),
CurrentUsersSql.usersSubquery("u_target")
);
List<String> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, loginAnyCase);
ps.setString(2, loginAnyCase);
ps.setInt(3, relType);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String v = rs.getString("friend_login");
if (v != null) out.add(v);
}
}
}
""".formatted(CurrentUsersSql.usersSubquery("u_actor"));
List<String> out=new ArrayList<>();
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,loginAnyCase); ps.setInt(2,relType); try(ResultSet rs=ps.executeQuery()){while(rs.next()){String v=rs.getString("friend_login");if(v!=null)out.add(v);}}}
return out;
}
@@ -189,21 +158,17 @@ public final class ConnectionsStateDAO {
}
String sql = """
INSERT INTO connections_state (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) DO UPDATE SET
to_bch_name=excluded.to_bch_name,
to_block_number=excluded.to_block_number,
to_block_hash=excluded.to_block_hash
INSERT INTO connections_state (login, rel_type, to_login, to_block_number, to_block_hash)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(login, rel_type, to_login, to_block_number, to_block_hash) DO NOTHING
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, relType);
ps.setString(3, toLogin);
ps.setString(4, toBchName);
if (toBlockNumber == null) ps.setNull(5, java.sql.Types.INTEGER); else ps.setInt(5, toBlockNumber);
ps.setBytes(6, toBlockHash);
if (toBlockNumber == null) ps.setNull(4, java.sql.Types.INTEGER); else ps.setInt(4, toBlockNumber);
ps.setBytes(5, toBlockHash);
ps.executeUpdate();
}
}
@@ -94,6 +94,7 @@ public final class SolanaUserPdaCurrentDAO {
FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(NULLIF(u.blockchain_forks_json, ''), '[]')::jsonb) AS fork_item(value)
WHERE fork_item.value ->> 'blockchainKey' = ?
AND ((fork_item.value ->> 'forkIndex')::integer + 1) BETWEEN 1 AND 999
LIMIT 1
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
@@ -106,11 +106,12 @@ public final class SubscriptionsDAO {
WITH subs AS (
-- 1) FOLLOW-каналы
SELECT
cs.to_login AS channel_login,
cs.to_bch_name AS channel_bch_name
cs.to_login AS channel_login,
cn.owner_bch_name AS channel_bch_name
FROM connections_state cs
WHERE cs.login = ?
AND cs.rel_type = ?
JOIN channel_names_state cn ON LOWER(cn.owner_login)=LOWER(cs.to_login)
AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
WHERE cs.login = ? AND cs.rel_type = ?
UNION
@@ -37,9 +37,8 @@ public final class UserProfileStateDAO {
/**
* Effective outgoing social relation with priority close_friend > friend > contact > none.
*
* Use the canonical relation lookup instead of comparing only connections_state.to_login:
* older/current blocks may address a user by blockchain name, so a direct to_login-only
* query can incorrectly report "none" even when ListContacts/Connections sees the relation.
* Use the canonical relation lookup so all UI paths share the same normalized login matching.
* Stage 2 connections_state identity is logical and no longer contains a physical target fork.
*/
public String getEffectiveRelationType(Connection c, String ownerLogin, String targetLogin) throws SQLException {
if (ownerLogin == null || ownerLogin.isBlank() || targetLogin == null || targetLogin.isBlank()) return "none";
@@ -124,7 +123,7 @@ public final class UserProfileStateDAO {
} else if ("following".equals(mode)) {
sql="""
SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash
FROM connections_state cs JOIN channel_names_state cn ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
FROM connections_state cs JOIN channel_names_state cn ON LOWER(cn.owner_login)=LOWER(cs.to_login) AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1
ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
""";
@@ -4,7 +4,7 @@ package shine.db.entities;
* Запись блока (таблица blocks) — обновлённая модель под новый формат.
*
* Храним:
* - login, bch_name (как было в проекте, чтобы не ломать общую БД)
* - login + fork_number (physical fork identity; bch_name is derived only for display/API)
* - block_number (глобальный номер в этой цепочке)
* - block_bytes (полный блок: preimage + signature)
* - block_hash (32 байта вычисленный SHA-256(preimage))
@@ -21,7 +21,7 @@ package shine.db.entities;
public class BlockEntry {
private String login;
private String bchName;
private int forkNumber;
private int blockNumber;
@@ -31,7 +31,7 @@ public class BlockEntry {
private byte[] blockBytes;
private String toLogin;
private String toBchName;
private Integer toForkNumber;
private Integer toBlockNumber;
private byte[] toBlockHash;
@@ -55,8 +55,14 @@ public class BlockEntry {
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getBchName() { return bchName; }
public void setBchName(String bchName) { this.bchName = bchName; }
public int getForkNumber() { return forkNumber; }
public void setForkNumber(int forkNumber) { if (forkNumber < 1 || forkNumber > 999) throw new IllegalArgumentException("forkNumber must be 1..999"); this.forkNumber = forkNumber; }
/** Compatibility/display helper. DB does not store bch_name anymore. */
public String getBchName() {
if (login == null || login.isBlank() || forkNumber < 1 || forkNumber > 999) return null;
return login + "-" + String.format("%03d", forkNumber);
}
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
@@ -73,8 +79,14 @@ public class BlockEntry {
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public String getToBchName() { return toBchName; }
public void setToBchName(String toBchName) { this.toBchName = toBchName; }
/** Derived historical display value only; never part of target identity. */
public String getToBchName() {
if (toLogin == null || toLogin.isBlank() || toForkNumber == null || toForkNumber < 1 || toForkNumber > 999) return null;
return toLogin + "-" + String.format("%03d", toForkNumber);
}
public Integer getToForkNumber() { return toForkNumber; }
public void setToForkNumber(Integer toForkNumber) { if (toForkNumber != null && (toForkNumber < 0 || toForkNumber > 999)) throw new IllegalArgumentException("toForkNumber must be 0..999"); this.toForkNumber = toForkNumber; }
public Integer getToBlockNumber() { return toBlockNumber; }
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
@@ -16,8 +16,8 @@ import java.util.Base64;
*/
public final class BlockchainStateEntry {
private String blockchainName;
private String login;
private int forkNumber;
private String blockchainKey; // Base64(32)
@@ -31,8 +31,22 @@ public final class BlockchainStateEntry {
public BlockchainStateEntry() {}
public String getBlockchainName() { return blockchainName; }
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
public String getBlockchainName() {
if (login == null || login.isBlank() || forkNumber < 1 || forkNumber > 999) return null;
return login + "-" + String.format("%03d", forkNumber);
}
/** Compatibility setter for API/import code; DB stores login + forkNumber. */
public void setBlockchainName(String blockchainName) {
if (blockchainName == null) return;
int dash=blockchainName.lastIndexOf('-');
if (dash <= 0 || blockchainName.length()-dash-1 != 3) throw new IllegalArgumentException("Bad blockchainName: "+blockchainName);
int parsedFork=Integer.parseInt(blockchainName.substring(dash+1));
if (parsedFork < 1 || parsedFork > 999) throw new IllegalArgumentException("forkNumber must be 1..999");
this.login=blockchainName.substring(0,dash);
this.forkNumber=parsedFork;
}
public int getForkNumber() { return forkNumber; }
public void setForkNumber(int forkNumber) { if (forkNumber < 1 || forkNumber > 999) throw new IllegalArgumentException("forkNumber must be 1..999"); this.forkNumber = forkNumber; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
@@ -1,5 +1,6 @@
-- SHiNE PostgreSQL runtime schema v1
-- Дата: 2026-07-28
-- SHiNE PostgreSQL database format v2 / runtime schema revision 29
-- Stage 2 baseline. This file is only for a NEW, EMPTY database.
-- Дата: 2026-09-29
--
-- Назначение:
-- - поднять пустую PostgreSQL БД сервера SHiNE с нуля;
@@ -15,14 +16,42 @@
BEGIN;
-- Safety guard: DB v2 is a fresh-baseline format, not an in-place migration.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_type = 'BASE TABLE'
) THEN
RAISE EXCEPTION 'SHiNE_DB_V2 bootstrap requires an empty PostgreSQL schema; legacy/in-place migration is disabled';
END IF;
END
$$;
CREATE TABLE IF NOT EXISTS db_schema_version (
id INTEGER PRIMARY KEY,
schema_version INTEGER NOT NULL,
updated_at_ms BIGINT NOT NULL
);
-- Hard database-generation marker. Stage 2 server MUST NOT run on an older SHiNE DB.
CREATE TABLE IF NOT EXISTS shine_database_metadata (
id INTEGER PRIMARY KEY CHECK (id = 1),
database_format TEXT NOT NULL CHECK (database_format = 'SHiNE_DB_V2'),
database_generation INTEGER NOT NULL CHECK (database_generation = 2),
created_at_ms BIGINT NOT NULL
);
INSERT INTO shine_database_metadata(id, database_format, database_generation, created_at_ms)
VALUES (1, 'SHiNE_DB_V2', 2, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO NOTHING;
COMMENT ON TABLE shine_database_metadata IS 'SHiNE database generation marker. Stage 2 requires SHiNE_DB_V2 / generation 2.';
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 26, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
VALUES (1, 29, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
@@ -494,6 +523,7 @@ CREATE TABLE IF NOT EXISTS blocks (
block_bytes BYTEA NOT NULL,
to_login TEXT,
to_bch_name TEXT,
to_fork_number INTEGER CHECK (to_fork_number IS NULL OR (to_fork_number >= 0 AND to_fork_number <= 999)),
to_block_number INTEGER CHECK (to_block_number IS NULL OR to_block_number >= 0),
to_block_hash BYTEA,
block_hash BYTEA NOT NULL,
@@ -2184,8 +2214,330 @@ ALTER TABLE solana_user_pda_current
REFERENCES key_rotation_sessions(id)
ON DELETE SET NULL;
-- Stage 2 final schema: numeric physical forks and logical cross-fork targets.
-- Stage 2: physical fork identity = (login,fork_number); logical target = (login,block_number,hash).
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT chk_solana_current_blockchain_name_fork
CHECK (blockchain_name ~ '-[0-9]{3}$'
AND substring(blockchain_name from '([0-9]{3})$')::INTEGER BETWEEN 1 AND 999);
ALTER TABLE blockchain_state ADD COLUMN IF NOT EXISTS fork_number INTEGER;
UPDATE blockchain_state
SET fork_number = NULLIF(regexp_replace(blockchain_name, '^.*-([0-9]{3})$', '\1'), blockchain_name)::INTEGER
WHERE fork_number IS NULL AND blockchain_name ~ '-[0-9]{3}$';
ALTER TABLE blockchain_state ALTER COLUMN fork_number SET NOT NULL;
ALTER TABLE blockchain_state ADD CONSTRAINT chk_blockchain_state_fork CHECK (fork_number BETWEEN 1 AND 999);
CREATE UNIQUE INDEX IF NOT EXISTS uq_blockchain_state_login_fork ON blockchain_state(login,fork_number);
ALTER TABLE blocks ADD COLUMN IF NOT EXISTS fork_number INTEGER;
UPDATE blocks b SET fork_number=s.fork_number FROM blockchain_state s WHERE b.bch_name=s.blockchain_name AND b.fork_number IS NULL;
ALTER TABLE blocks ALTER COLUMN fork_number SET NOT NULL;
ALTER TABLE blocks ADD CONSTRAINT chk_blocks_fork CHECK (fork_number BETWEEN 1 AND 999);
-- Triggers stay attached when tables are renamed; replace their functions below before new writes.
ALTER TABLE blocks DROP CONSTRAINT IF EXISTS blocks_bch_name_fkey;
ALTER TABLE blocks RENAME TO blocks_store;
ALTER TABLE blockchain_state RENAME TO blockchain_state_store;
ALTER TABLE blocks_store DROP COLUMN bch_name;
ALTER TABLE blocks_store DROP COLUMN to_bch_name;
ALTER TABLE blockchain_state_store DROP CONSTRAINT IF EXISTS blockchain_state_pkey;
ALTER TABLE blockchain_state_store DROP COLUMN blockchain_name;
ALTER TABLE blockchain_state_store ADD PRIMARY KEY(login,fork_number);
ALTER TABLE blocks_store ADD CONSTRAINT fk_blocks_physical_fork FOREIGN KEY(login,fork_number)
REFERENCES blockchain_state_store(login,fork_number);
DROP INDEX IF EXISTS idx_blocks_by_chain_number;
DROP INDEX IF EXISTS idx_blocks_to_target;
DROP INDEX IF EXISTS idx_blocks_by_line;
CREATE UNIQUE INDEX uq_blocks_physical_number ON blocks_store(login,fork_number,block_number);
CREATE INDEX idx_blocks_to_logical_target ON blocks_store(to_login,to_block_number,to_block_hash);
CREATE INDEX idx_blocks_by_line_v29 ON blocks_store(login,fork_number,line_code,this_line_number);
-- Compatibility views: names are derived, never stored.
CREATE VIEW blockchain_state AS
SELECT login || '-' || lpad(fork_number::text,3,'0') AS blockchain_name, s.* FROM blockchain_state_store s;
CREATE VIEW blocks AS
SELECT b.login,
b.login || '-' || lpad(b.fork_number::text,3,'0') AS bch_name,
b.fork_number,b.block_number,b.msg_type,b.msg_sub_type,b.block_bytes,
b.to_login,
CASE WHEN b.to_login IS NOT NULL AND b.to_fork_number BETWEEN 1 AND 999
THEN b.to_login || '-' || lpad(b.to_fork_number::text,3,'0') END AS to_bch_name,
b.to_fork_number,b.to_block_number,b.to_block_hash,b.block_hash,b.block_signature,b.data_item_id,
b.arweave_publish_pending,b.arweave_published_at_ms,b.arweave_root_tx_id,b.edited_by_block_number,
b.line_code,b.prev_line_number,b.prev_line_hash,b.this_line_number
FROM blocks_store b;
-- Channel read state keeps the signed/display owner_bch_name, but identity no longer depends on a fork.
ALTER TABLE channel_read_state DROP CONSTRAINT IF EXISTS channel_read_state_pkey;
ALTER TABLE channel_read_state ADD PRIMARY KEY(viewer_login,owner_login,channel_name);
-- Derived social state is cheap to rebuild and intentionally loses physical fork names.
DROP TABLE IF EXISTS reactions_state CASCADE;
DROP TABLE IF EXISTS message_stats CASCADE;
DROP TABLE IF EXISTS connections_state CASCADE;
CREATE TABLE connections_state(
login TEXT NOT NULL, rel_type INTEGER NOT NULL, to_login TEXT NOT NULL,
to_block_number INTEGER NOT NULL, to_block_hash BYTEA NOT NULL,
UNIQUE(login,rel_type,to_login,to_block_number,to_block_hash)
);
CREATE INDEX idx_connections_state_login_v29 ON connections_state(login);
CREATE INDEX idx_connections_state_to_login_v29 ON connections_state(to_login);
CREATE INDEX idx_connections_state_target_v29 ON connections_state(to_login,to_block_number,to_block_hash);
CREATE TABLE message_stats(
to_login TEXT NOT NULL, to_block_number INTEGER NOT NULL, to_block_hash BYTEA NOT NULL,
likes_count INTEGER NOT NULL DEFAULT 0,
primary_likes_count INTEGER NOT NULL DEFAULT 0,
shining_likes_count INTEGER NOT NULL DEFAULT 0,
replies_count INTEGER NOT NULL DEFAULT 0,
UNIQUE(to_login,to_block_number,to_block_hash)
);
CREATE INDEX idx_message_stats_target_v29 ON message_stats(to_login,to_block_number,to_block_hash);
CREATE TABLE reactions_state(
from_login TEXT NOT NULL, reaction_type INTEGER NOT NULL,
to_login TEXT NOT NULL, to_block_number INTEGER NOT NULL, to_block_hash BYTEA NOT NULL,
last_sub_type INTEGER NOT NULL,
UNIQUE(from_login,reaction_type,to_login,to_block_number,to_block_hash)
);
CREATE INDEX idx_reactions_state_target_v29 ON reactions_state(to_login,to_block_number,to_block_hash);
CREATE INDEX idx_reactions_state_actor_v29 ON reactions_state(from_login,reaction_type);
DROP TABLE IF EXISTS message_views_state;
CREATE TABLE message_views_state(
viewer_login TEXT NOT NULL, to_login TEXT NOT NULL, to_block_number INTEGER NOT NULL,
to_block_hash BYTEA NOT NULL, first_seen_at_ms BIGINT NOT NULL,
PRIMARY KEY(viewer_login,to_login,to_block_number,to_block_hash)
);
CREATE INDEX idx_message_views_target_v29 ON message_views_state(to_login,to_block_number,to_block_hash);
CREATE OR REPLACE FUNCTION shine_refresh_user_stats(p_login TEXT) RETURNS VOID LANGUAGE plpgsql AS $$
DECLARE v_now BIGINT:=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT);
BEGIN
IF p_login IS NULL OR btrim(p_login)='' THEN RETURN; END IF;
INSERT INTO user_stats_state(login,owned_public_channels_count,following_users_count,following_channels_count,close_friends_count,friends_count,
primary_confirmations_received_count,primary_confirmations_given_count,shine_confirmations_received_count,shine_confirmations_given_count,updated_at_ms)
SELECT p_login,
(SELECT COUNT(*)::INT FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(p_login) AND cn.channel_type_code=1),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cs.to_block_number=0),
(SELECT COUNT(*)::INT FROM connections_state cs JOIN channel_names_state cn ON LOWER(cn.owner_login)=LOWER(cs.to_login) AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=30 AND cn.channel_type_code=1),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=10),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=14 AND NOT EXISTS(SELECT 1 FROM connections_state cf WHERE LOWER(cf.login)=LOWER(p_login) AND LOWER(cf.to_login)=LOWER(cs.to_login) AND cf.rel_type=10)),
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=80 AND shine_is_primary(cs.login) AND shine_target_accepts_primary_confirmation(p_login)),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=80 AND shine_is_primary(p_login) AND shine_target_accepts_primary_confirmation(cs.to_login)),
(SELECT COUNT(DISTINCT LOWER(cs.login))::INT FROM connections_state cs WHERE LOWER(cs.to_login)=LOWER(p_login) AND cs.rel_type=70 AND shine_is_primary(cs.login) AND shine_is_shining(cs.login) AND shine_target_accepts_shine_confirmation(p_login)),
(SELECT COUNT(DISTINCT LOWER(cs.to_login))::INT FROM connections_state cs WHERE LOWER(cs.login)=LOWER(p_login) AND cs.rel_type=70 AND shine_is_primary(p_login) AND shine_is_shining(p_login) AND shine_target_accepts_shine_confirmation(cs.to_login)),v_now
ON CONFLICT(login) DO UPDATE SET owned_public_channels_count=EXCLUDED.owned_public_channels_count,following_users_count=EXCLUDED.following_users_count,following_channels_count=EXCLUDED.following_channels_count,close_friends_count=EXCLUDED.close_friends_count,friends_count=EXCLUDED.friends_count,primary_confirmations_received_count=EXCLUDED.primary_confirmations_received_count,primary_confirmations_given_count=EXCLUDED.primary_confirmations_given_count,shine_confirmations_received_count=EXCLUDED.shine_confirmations_received_count,shine_confirmations_given_count=EXCLUDED.shine_confirmations_given_count,updated_at_ms=EXCLUDED.updated_at_ms;
END $$;
CREATE OR REPLACE FUNCTION shine_refresh_message_qualified_likes_for_actor(p_login TEXT) RETURNS VOID LANGUAGE plpgsql AS $$
DECLARE r RECORD;
BEGIN
FOR r IN SELECT DISTINCT to_login,to_block_number,to_block_hash FROM reactions_state WHERE LOWER(from_login)=LOWER(p_login) AND reaction_type=1 AND last_sub_type=1 LOOP
UPDATE message_stats ms SET
primary_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1 AND x.to_login=r.to_login AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash AND shine_is_primary(x.from_login)),
shining_likes_count=(SELECT COUNT(*)::INT FROM reactions_state x WHERE x.reaction_type=1 AND x.last_sub_type=1 AND x.to_login=r.to_login AND x.to_block_number=r.to_block_number AND x.to_block_hash=r.to_block_hash AND shine_is_primary(x.from_login) AND shine_is_shining(x.from_login))
WHERE ms.to_login=r.to_login AND ms.to_block_number=r.to_block_number AND ms.to_block_hash=r.to_block_hash;
END LOOP;
END $$;
CREATE OR REPLACE FUNCTION shine_refresh_channel_follow_stats(p_bch_name TEXT,p_block_number INTEGER,p_block_hash BYTEA) RETURNS VOID LANGUAGE plpgsql AS $$
DECLARE v_owner TEXT; v_type INTEGER; v_count INTEGER; v_now BIGINT:=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT);
BEGIN
SELECT owner_login,channel_type_code INTO v_owner,v_type FROM channel_names_state WHERE owner_bch_name=p_bch_name AND channel_root_block_number=p_block_number AND channel_root_block_hash=p_block_hash LIMIT 1;
IF v_owner IS NULL THEN RETURN; END IF;
SELECT COUNT(DISTINCT cs.login)::INTEGER INTO v_count FROM connections_state cs WHERE cs.rel_type=30 AND LOWER(cs.to_login)=LOWER(v_owner) AND cs.to_block_number=p_block_number AND cs.to_block_hash=p_block_hash;
INSERT INTO channel_stats_state(owner_bch_name,channel_root_block_number,channel_root_block_hash,owner_login,channel_type_code,subscribers_count,updated_at_ms)
VALUES(p_bch_name,p_block_number,p_block_hash,v_owner,v_type,COALESCE(v_count,0),v_now)
ON CONFLICT(owner_bch_name,channel_root_block_number,channel_root_block_hash) DO UPDATE SET subscribers_count=EXCLUDED.subscribers_count,owner_login=EXCLUDED.owner_login,channel_type_code=EXCLUDED.channel_type_code,updated_at_ms=EXCLUDED.updated_at_ms;
END $$;
-- Channel-name projection must also use logical connection targets; the physical owner name is display/state only.
CREATE OR REPLACE FUNCTION shine_channel_names_state_stats_ai()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
now_ms BIGINT;
public_subscribers_count INTEGER;
r RECORD;
BEGIN
now_ms := CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT);
IF NEW.owner_login IS NULL OR btrim(NEW.owner_login) = '' THEN
RETURN NEW;
END IF;
IF NEW.channel_type_code = 1 THEN
IF EXISTS (
SELECT 1 FROM solana_user_pda_current su WHERE LOWER(su.login)=LOWER(NEW.owner_login)
) THEN
INSERT INTO user_stats_state (
login, owned_public_channels_count, following_users_count, following_channels_count,
close_friends_count, updated_at_ms
) VALUES (NEW.owner_login, 1, 0, 0, 0, now_ms)
ON CONFLICT (login) DO UPDATE SET
owned_public_channels_count = user_stats_state.owned_public_channels_count + 1,
updated_at_ms = EXCLUDED.updated_at_ms;
END IF;
SELECT COUNT(*)::INTEGER
INTO public_subscribers_count
FROM connections_state cs
WHERE cs.rel_type = 30
AND LOWER(cs.to_login)=LOWER(NEW.owner_login)
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash;
INSERT INTO channel_stats_state (
owner_bch_name, channel_root_block_number, channel_root_block_hash,
owner_login, channel_type_code, subscribers_count, updated_at_ms
) VALUES (
NEW.owner_bch_name, NEW.channel_root_block_number, NEW.channel_root_block_hash,
NEW.owner_login, NEW.channel_type_code, COALESCE(public_subscribers_count, 0), now_ms
)
ON CONFLICT (owner_bch_name, channel_root_block_number, channel_root_block_hash) DO UPDATE SET
owner_login = EXCLUDED.owner_login,
channel_type_code = EXCLUDED.channel_type_code,
subscribers_count = EXCLUDED.subscribers_count,
updated_at_ms = EXCLUDED.updated_at_ms;
-- A channel may disappear during cleanup and reappear during replay. Recompute followers from logical state.
FOR r IN
SELECT DISTINCT cs.login
FROM connections_state cs
WHERE cs.rel_type = 30
AND LOWER(cs.to_login)=LOWER(NEW.owner_login)
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
LOOP
PERFORM shine_refresh_user_stats(r.login);
END LOOP;
ELSE
WITH pending AS (
SELECT cs.login, COUNT(*)::INTEGER AS cnt
FROM connections_state cs
WHERE cs.rel_type = 30
AND LOWER(cs.to_login)=LOWER(NEW.owner_login)
AND cs.to_block_number = NEW.channel_root_block_number
AND cs.to_block_hash = NEW.channel_root_block_hash
GROUP BY cs.login
)
UPDATE user_stats_state us
SET following_channels_count = GREATEST(0, us.following_channels_count - pending.cnt),
updated_at_ms = now_ms
FROM pending
WHERE LOWER(us.login)=LOWER(pending.login);
END IF;
RETURN NEW;
END;
$$;
-- Physical chain integrity uses numeric fork identity.
CREATE OR REPLACE FUNCTION shine_blocks_line_integrity_bi() RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE prev_block RECORD;
BEGIN
IF NEW.line_code IS NULL AND NEW.prev_line_number IS NULL AND NEW.prev_line_hash IS NULL AND NEW.this_line_number IS NULL THEN RETURN NEW; END IF;
IF NEW.msg_type NOT IN (0,1,3,4) THEN RAISE EXCEPTION 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE'; END IF;
IF NEW.line_code IS NULL OR NEW.prev_line_number IS NULL OR NEW.prev_line_hash IS NULL OR NEW.this_line_number IS NULL THEN RAISE EXCEPTION 'LINE_ERR_PARTIAL_FIELDS'; END IF;
SELECT * INTO prev_block FROM blocks_store WHERE login=NEW.login AND fork_number=NEW.fork_number AND block_number=NEW.prev_line_number LIMIT 1;
IF NOT FOUND THEN RAISE EXCEPTION 'LINE_ERR_NO_PREV'; END IF;
IF prev_block.block_hash IS DISTINCT FROM NEW.prev_line_hash THEN RAISE EXCEPTION 'LINE_ERR_PREV_HASH_MISMATCH'; END IF;
IF NEW.prev_line_number<>NEW.line_code AND prev_block.line_code IS DISTINCT FROM NEW.line_code THEN RAISE EXCEPTION 'LINE_ERR_LINE_CODE_MISMATCH'; END IF;
IF NEW.prev_line_number=NEW.line_code THEN
IF NEW.this_line_number<>(CASE WHEN NEW.msg_type=1 THEN 0 ELSE 1 END) THEN RAISE EXCEPTION 'LINE_ERR_FIRST_STEP_BAD_THIS'; END IF; RETURN NEW;
END IF;
IF prev_block.this_line_number IS NULL THEN RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; END IF;
IF NEW.msg_type=1 THEN
IF NEW.this_line_number<>prev_block.this_line_number AND NEW.this_line_number<>prev_block.this_line_number+1 THEN RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; END IF;
ELSE
IF NEW.this_line_number<>prev_block.this_line_number+1 THEN RAISE EXCEPTION 'LINE_ERR_THIS_LINE_BAD_STEP'; END IF;
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION shine_blocks_connection_state_ai() RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE positive_rel_type INTEGER; channel_bch TEXT;
BEGIN
IF NEW.msg_type<>3 OR NEW.to_login IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
IF NEW.msg_sub_type IN (10,14,20,30,40,50,52,54,60,70,74,80) THEN
DELETE FROM connections_state WHERE login=NEW.login AND rel_type=NEW.msg_sub_type AND to_login=NEW.to_login AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
INSERT INTO connections_state(login,rel_type,to_login,to_block_number,to_block_hash)
VALUES(NEW.login,NEW.msg_sub_type,NEW.to_login,NEW.to_block_number,NEW.to_block_hash);
ELSE
positive_rel_type := CASE NEW.msg_sub_type WHEN 11 THEN 10 WHEN 15 THEN 14 WHEN 21 THEN 20 WHEN 31 THEN 30 WHEN 41 THEN 40 WHEN 51 THEN 50 WHEN 53 THEN 52 WHEN 55 THEN 54 WHEN 61 THEN 60 WHEN 71 THEN 70 WHEN 75 THEN 74 WHEN 81 THEN 80 ELSE NULL END;
IF positive_rel_type IS NOT NULL THEN DELETE FROM connections_state WHERE login=NEW.login AND rel_type=positive_rel_type AND to_login=NEW.to_login AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash; END IF;
END IF;
PERFORM shine_refresh_user_stats(NEW.login);
PERFORM shine_refresh_user_stats(NEW.to_login);
IF NEW.msg_sub_type IN (30,31) AND NEW.to_block_number>0 THEN
SELECT owner_bch_name INTO channel_bch FROM channel_names_state WHERE LOWER(owner_login)=LOWER(NEW.to_login) AND channel_root_block_number=NEW.to_block_number AND channel_root_block_hash=NEW.to_block_hash LIMIT 1;
IF channel_bch IS NOT NULL THEN PERFORM shine_refresh_channel_follow_stats(channel_bch,NEW.to_block_number,NEW.to_block_hash); END IF;
END IF;
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_like_ai() RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE previous_sub_type INTEGER;
BEGIN
IF NEW.msg_type<>2 OR NEW.msg_sub_type NOT IN (1,2) THEN RETURN NEW; END IF;
IF NEW.to_login IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
INSERT INTO message_stats(to_login,to_block_number,to_block_hash) VALUES(NEW.to_login,NEW.to_block_number,NEW.to_block_hash)
ON CONFLICT(to_login,to_block_number,to_block_hash) DO NOTHING;
SELECT last_sub_type INTO previous_sub_type FROM reactions_state WHERE from_login=NEW.login AND reaction_type=1 AND to_login=NEW.to_login AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash LIMIT 1;
IF NEW.msg_sub_type=1 AND previous_sub_type IS DISTINCT FROM 1 THEN
UPDATE message_stats SET likes_count=likes_count+1,
primary_likes_count=primary_likes_count+CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END,
shining_likes_count=shining_likes_count+CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END
WHERE to_login=NEW.to_login AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
ELSIF NEW.msg_sub_type=2 AND previous_sub_type=1 THEN
UPDATE message_stats SET likes_count=GREATEST(0,likes_count-1),
primary_likes_count=GREATEST(0,primary_likes_count-CASE WHEN shine_is_primary(NEW.login) THEN 1 ELSE 0 END),
shining_likes_count=GREATEST(0,shining_likes_count-CASE WHEN shine_is_primary(NEW.login) AND shine_is_shining(NEW.login) THEN 1 ELSE 0 END)
WHERE to_login=NEW.to_login AND to_block_number=NEW.to_block_number AND to_block_hash=NEW.to_block_hash;
END IF;
INSERT INTO reactions_state(from_login,reaction_type,to_login,to_block_number,to_block_hash,last_sub_type)
VALUES(NEW.login,1,NEW.to_login,NEW.to_block_number,NEW.to_block_hash,NEW.msg_sub_type)
ON CONFLICT(from_login,reaction_type,to_login,to_block_number,to_block_hash) DO UPDATE SET last_sub_type=EXCLUDED.last_sub_type;
RETURN NEW;
END $$;
-- Existing REPLY counter semantics are preserved; only the removed physical target-name column is adapted.
CREATE OR REPLACE FUNCTION shine_blocks_message_stats_reply_ai() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF NEW.msg_type<>1 OR NEW.msg_sub_type<>20 THEN RETURN NEW; END IF;
IF NEW.to_login IS NULL OR NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
INSERT INTO message_stats(to_login,to_block_number,to_block_hash,replies_count)
VALUES(NEW.to_login,NEW.to_block_number,NEW.to_block_hash,1)
ON CONFLICT(to_login,to_block_number,to_block_hash) DO UPDATE SET replies_count=message_stats.replies_count+1;
RETURN NEW;
END $$;
-- EDIT applies to own physical fork; no edits_count aggregate is maintained.
CREATE OR REPLACE FUNCTION shine_blocks_edit_apply_ai() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF NEW.msg_type<>1 OR NEW.msg_sub_type NOT IN (11,21) THEN RETURN NEW; END IF;
IF NEW.to_block_number IS NULL OR NEW.to_block_hash IS NULL THEN RETURN NEW; END IF;
UPDATE blocks_store SET edited_by_block_number=NEW.block_number
WHERE login=NEW.login AND fork_number=NEW.fork_number AND block_number=NEW.to_block_number AND block_hash=NEW.to_block_hash;
RETURN NEW;
END $$;
-- Triggers moved with blocks_store on rename; make sure they exist there.
DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi ON blocks_store;
CREATE TRIGGER trg_blocks_line_integrity_bi BEFORE INSERT ON blocks_store FOR EACH ROW EXECUTE FUNCTION shine_blocks_line_integrity_bi();
DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai ON blocks_store;
CREATE TRIGGER trg_blocks_connection_state_ai AFTER INSERT ON blocks_store FOR EACH ROW EXECUTE FUNCTION shine_blocks_connection_state_ai();
DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai ON blocks_store;
CREATE TRIGGER trg_blocks_message_stats_like_ai AFTER INSERT ON blocks_store FOR EACH ROW EXECUTE FUNCTION shine_blocks_message_stats_like_ai();
DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai ON blocks_store;
CREATE TRIGGER trg_blocks_message_stats_reply_ai AFTER INSERT ON blocks_store FOR EACH ROW EXECUTE FUNCTION shine_blocks_message_stats_reply_ai();
DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai ON blocks_store;
CREATE TRIGGER trg_blocks_edit_apply_ai AFTER INSERT ON blocks_store FOR EACH ROW EXECUTE FUNCTION shine_blocks_edit_apply_ai();
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
VALUES(1,27,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
VALUES(1,29,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
COMMIT;
@@ -252,7 +252,7 @@ public final class SolanaUserPdaImportService {
c = payloadEnd;
}
if (blockchainKey32 == null || clientKey32 == null || forkCount <= 0) return null;
if (blockchainKey32 == null || clientKey32 == null || forkCount <= 0 || forkCount > 999) return null;
String blockchainName = login + "-" + String.format(Locale.ROOT, "%03d", forkCount);
return new ParsedSolanaUser(
login,
@@ -7,6 +7,7 @@ import blockchain.body.BodyHasLine;
import blockchain.body.BodyHasTarget;
import blockchain.body.ConnectionBody;
import blockchain.body.CreateChannelBody;
import blockchain.body.HeaderBody;
import blockchain.body.StatusActionBody;
import blockchain.body.TextReplyBody;
import blockchain.body.TextLineBody;
@@ -320,6 +321,18 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex);
}
// Canonical chain bootstrap: block #0 is always HEADER.
if (serverLastNum == -1) {
if (!(block.body instanceof HeaderBody headerBody)) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "header_required_as_block_zero", serverLastNum, serverLastHashHex);
}
if (!login.equals(headerBody.login)) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "header_login_mismatch", serverLastNum, serverLastHashHex);
}
} else if (block.body instanceof HeaderBody) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "header_must_be_block_zero", serverLastNum, serverLastHashHex);
}
// Репосты временно отключены до будущей реализации.
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
if ((block.type & 0xFFFF) == 1
@@ -470,6 +483,20 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
}
}
if ((block.type & 0xFFFF) == 1
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
try {
String editError = validateEditReplyTarget(blockchainName, block);
if (editError != null) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, editError, serverLastNum, serverLastHashHex);
}
} catch (Exception e) {
log.error("AddBlock: edit_reply_target_check_failed (login={}, blockchainName={}, blockNumber={})",
login, blockchainName, block.blockNumber, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex);
}
}
if ((block.type & 0xFFFF) == 5) {
try {
String statusError = validateStatusActionTarget(block);
@@ -513,6 +540,11 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex);
}
if (serverLastNum == -1 && block.body instanceof HeaderBody headerBody
&& !Arrays.equals(headerBody.initialBlockchainKey32, pubKey32)) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "header_initial_key_mismatch", serverLastNum, serverLastHashHex);
}
// 6) ANS-104 Ed25519 подпись и owner
boolean sigOk;
try {
@@ -541,7 +573,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
prevLineHash32 = bl.prevLineBlockHash32();
thisLineNumber = bl.lineSeq();
// Нормализация: -1 не пишем в БД (для совместимости со старым TextBody)
// Нормализация: пустой line anchor (-1) не пишем в DB projection
if (prevLineNumber != null && prevLineNumber == -1) {
lineCode = null;
prevLineNumber = null;
@@ -575,7 +607,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
try {
BlockEntry be = new BlockEntry();
be.setLogin(login);
be.setBchName(blockchainName);
be.setForkNumber(java.util.Objects.requireNonNull(BlockchainNameUtil.forkNumberFromBlockchainName(blockchainName), "invalid blockchainName fork"));
be.setBlockNumber(block.blockNumber);
be.setMsgType(block.type & 0xFFFF);
@@ -593,25 +625,26 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
be.setPrevLineHash(prevLineHash32);
be.setThisLineNumber(thisLineNumber);
// target columns (optional)
// target columns (optional). Signed identity is login + blockNumber + blockHash.
// toForkNumber is historical metadata only and never participates in target identity.
if (block.body instanceof BodyHasTarget t) {
String targetBchName = t.toBchName();
int type = block.type & 0xFFFF;
int sub = block.subType & 0xFFFF;
boolean isTextEdit = type == 1
&& (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
if (isTextEdit && (targetBchName == null || targetBchName.isBlank())) {
targetBchName = blockchainName;
String targetLogin = t.toLogin();
Integer targetForkNumber = t.toForkNumber();
if (isTextEdit) {
// EDIT target is compact and always belongs to this physical fork.
targetLogin = login;
targetForkNumber = null;
}
be.setToLogin(t.toLogin());
be.setToBchName(targetBchName);
be.setToLogin(targetLogin);
be.setToForkNumber(targetForkNumber);
be.setToBlockNumber(t.toBlockGlobalNumber());
be.setToBlockHash(t.toBlockHashBytes());
if (isTextEdit && (be.getToLogin() == null || be.getToLogin().isBlank()) && targetBchName != null && !targetBchName.isBlank()) {
be.setToLogin(BlockchainNameUtil.loginFromBlockchainName(targetBchName));
}
}
// edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели"
@@ -806,41 +839,58 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
}
private String validateEditPostTarget(String ownerBch, BchBlockEntry block) throws Exception {
if (!(block.body instanceof TextLineBody editBody)) return null;
if (!(block.body instanceof TextLineBody editBody)) return "bad_block_body";
Integer targetBlockNumber = editBody.toBlockGlobalNumber();
byte[] targetHash = editBody.toBlockHashBytes();
if (targetBlockNumber == null || targetHash == null || targetHash.length != 32) return "bad_block_body";
BlockEntry target = blocksDAO.getByNumber(ownerBch, targetBlockNumber);
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
return null;
return "edit_target_not_found";
}
if (target.getMsgType() != 1) return null;
if (target.getMsgSubType() == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)) {
return "entrypoint_edit_forbidden";
if (target.getMsgType() != 1 || target.getMsgSubType() != (MsgSubType.TEXT_POST & 0xFFFF)) {
return "edit_post_target_must_be_text_post";
}
return null;
}
private String validateEditReplyTarget(String ownerBch, BchBlockEntry block) throws Exception {
if (!(block.body instanceof TextReplyBody editBody)) return "bad_block_body";
Integer targetBlockNumber = editBody.toBlockGlobalNumber();
byte[] targetHash = editBody.toBlockHashBytes();
if (targetBlockNumber == null || targetHash == null || targetHash.length != 32) return "bad_block_body";
BlockEntry target = blocksDAO.getByNumber(ownerBch, targetBlockNumber);
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
return "edit_target_not_found";
}
if (target.getMsgType() != 1 || target.getMsgSubType() != (MsgSubType.TEXT_REPLY & 0xFFFF)) {
return "edit_reply_target_must_be_text_reply";
}
return null;
}
private String validateStatusActionTarget(BchBlockEntry block) throws Exception {
if (!(block.body instanceof StatusActionBody statusBody)) return "bad_block_body";
String targetBch = statusBody.toBchName();
String targetLogin = statusBody.toLogin();
Integer targetFork = statusBody.toForkNumber();
Integer targetBlockNumber = statusBody.toBlockGlobalNumber();
byte[] targetHash = statusBody.toBlockHashBytes();
if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
if (targetLogin == null || targetLogin.isBlank() || targetFork == null
|| targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
return "bad_block_body";
}
// forkNumber is historical only. It is used here solely as a physical lookup hint.
if (targetFork <= 0) return null;
String targetBch = BlockchainNameUtil.blockchainName(targetLogin, targetFork);
BlockEntry target = blocksDAO.getByNumber(targetBch, targetBlockNumber);
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
return null;
}
int statusSubType = block.subType & 0xFFFF;
if (statusSubType == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) {
if (target.getMsgType() != 5) {
return "status_confirmed_target_must_be_status_action";
}
return null;
return target.getMsgType() == 5 ? null : "status_confirmed_target_must_be_status_action";
}
if (target.getMsgType() != 1) return "status_action_target_not_allowed";
@@ -65,6 +65,7 @@ public final class Net_GetMyBlockchain_Handler implements JsonMessageHandler {
item.setMsgVersion(Short.toUnsignedInt(parsed.version));
if (parsed.body instanceof BodyHasTarget target) {
item.setToLogin(target.toLogin());
item.setToForkNumber(target.toForkNumber());
item.setToBlockNumber(target.toBlockGlobalNumber());
item.setToBlockHash(hex(target.toBlockHashBytes()));
}
@@ -34,6 +34,7 @@ public final class Net_GetMyBlockchain_Response extends Net_Response {
private int msgSubType;
private int msgVersion;
private String toLogin;
private Integer toForkNumber;
private Integer toBlockNumber;
private String toBlockHash;
private String blockBytesB64;
@@ -54,6 +55,8 @@ public final class Net_GetMyBlockchain_Response extends Net_Response {
public void setMsgVersion(int msgVersion) { this.msgVersion = msgVersion; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public Integer getToForkNumber() { return toForkNumber; }
public void setToForkNumber(Integer toForkNumber) { this.toForkNumber = toForkNumber; }
public Integer getToBlockNumber() { return toBlockNumber; }
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
public String getToBlockHash() { return toBlockHash; }
@@ -3,7 +3,6 @@ package server.logic.ws_protocol.JSON.handlers.channels;
import blockchain.BchBlockEntry;
import blockchain.body.BodyRecord;
import blockchain.body.CreateChannelBody;
import blockchain.body.TextBody;
import blockchain.body.TextLineBody;
import blockchain.body.TextReplyBody;
import shine.db.channels.ChannelNameRules;
@@ -188,7 +187,7 @@ final class ChannelsReadSupport {
SELECT login,bch_name,block_number,block_hash,block_bytes,this_line_number
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
AND to_block_number=? AND to_block_hash=?
ORDER BY block_number DESC
LIMIT 1
""";
@@ -196,9 +195,8 @@ final class ChannelsReadSupport {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
ps.setString(4, ownerBch);
ps.setInt(5, originalBlockNumber);
ps.setBytes(6, originalHash);
ps.setInt(4, originalBlockNumber);
ps.setBytes(5, originalHash);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
PostBlock pb = new PostBlock();
@@ -225,8 +223,6 @@ final class ChannelsReadSupport {
ti.text = trb.message;
} else if (e.body instanceof blockchain.body.StatusActionBody sab) {
ti.text = sab.message;
} else if (e.body instanceof TextBody tb) {
ti.text = tb.message;
}
return ti;
} catch (Exception ex) {
@@ -290,7 +286,6 @@ final class ChannelsReadSupport {
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
AND to_block_number=? AND to_block_hash=?
AND (to_bch_name=? OR to_bch_name IS NULL OR to_bch_name='')
ORDER BY block_number ASC
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
@@ -299,7 +294,6 @@ final class ChannelsReadSupport {
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
ps.setInt(4, originalBlock);
ps.setBytes(5, originalHash);
ps.setString(6, ownerBch);
try (ResultSet rs = ps.executeQuery()) {
List<PostBlock> out = new ArrayList<>();
while (rs.next()) {
@@ -317,13 +311,13 @@ final class ChannelsReadSupport {
}
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
String sql = "SELECT likes_count,primary_likes_count,shining_likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
String sql = "SELECT likes_count,primary_likes_count,shining_likes_count,replies_count FROM message_stats WHERE LOWER(to_login)=LOWER(?) AND to_block_number=? AND to_block_hash=? LIMIT 1";
int likesCount = 0;
int primaryLikesCount = 0;
int shiningLikesCount = 0;
int repliesCount = 0;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bch);
ps.setString(1, loginFromChainName(bch));
ps.setInt(2, blockNumber);
ps.setBytes(3, blockHash);
try (ResultSet rs = ps.executeQuery()) {
@@ -340,7 +334,7 @@ final class ChannelsReadSupport {
FROM blocks
WHERE msg_type = ?
AND msg_sub_type = ?
AND to_bch_name = ?
AND LOWER(to_login) = LOWER(?)
AND to_block_number = ?
AND to_block_hash = ?
""";
@@ -348,7 +342,7 @@ final class ChannelsReadSupport {
try (PreparedStatement ps = c.prepareStatement(ratingsSql)) {
ps.setInt(1, MSG_TYPE_TEXT);
ps.setInt(2, MsgSubType.TEXT_RATING);
ps.setString(3, bch);
ps.setString(3, loginFromChainName(bch));
ps.setInt(4, blockNumber);
ps.setBytes(5, blockHash);
try (ResultSet rs = ps.executeQuery()) {
@@ -601,26 +595,25 @@ final class ChannelsReadSupport {
if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) {
return false;
}
String targetLogin = loginFromChainName(toBch);
if (targetLogin == null || targetLogin.isBlank()) return false;
String sql = """
SELECT msg_sub_type
FROM blocks
WHERE LOWER(login) = LOWER(?)
AND msg_type = ?
AND to_bch_name = ?
SELECT last_sub_type
FROM reactions_state
WHERE LOWER(from_login) = LOWER(?)
AND reaction_type = 1
AND LOWER(to_login) = LOWER(?)
AND to_block_number = ?
AND to_block_hash = ?
ORDER BY block_number DESC
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, MSG_TYPE_REACTION);
ps.setString(3, toBch);
ps.setInt(4, toBlockNumber);
ps.setBytes(5, toBlockHash);
ps.setString(2, targetLogin);
ps.setInt(3, toBlockNumber);
ps.setBytes(4, toBlockHash);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return false;
return rs.getInt("msg_sub_type") == MsgSubType.REACTION_LIKE;
return rs.next() && rs.getInt("last_sub_type") == MsgSubType.REACTION_LIKE;
}
}
}
@@ -711,4 +704,8 @@ final class ChannelsReadSupport {
String ownerBlockchainName;
int channelRootBlockNumber;
}
static String loginFromChainName(String bch) {
if (bch == null) return null; int d=bch.lastIndexOf('-'); return d>0?bch.substring(0,d):bch;
}
}
@@ -53,7 +53,7 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
LEFT JOIN user_profile_state ups ON LOWER(ups.login) = LOWER(rs.from_login)
WHERE rs.reaction_type = 1
AND rs.last_sub_type = 1
AND rs.to_bch_name = ?
AND LOWER(rs.to_login) = LOWER(?)
AND rs.to_block_number = ?
AND rs.to_block_hash = ?
ORDER BY LOWER(rs.from_login)
@@ -63,7 +63,7 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
List<Net_GetMessageLikes_Response.UserItem> official = new ArrayList<>();
List<Net_GetMessageLikes_Response.UserItem> others = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, message.getBlockchainName().trim());
ps.setString(1, loginFromChainName(message.getBlockchainName().trim()));
ps.setInt(2, message.getBlockNumber());
ps.setBytes(3, blockHash);
try (ResultSet rs = ps.executeQuery()) {
@@ -98,4 +98,8 @@ public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
private static String loginFromChainName(String bch) {
if (bch == null) return null; int d=bch.lastIndexOf('-'); return d>0?bch.substring(0,d):bch;
}
}
@@ -56,8 +56,17 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
List<Net_GetMessageThread_Response.MessageNode> ancestors = new ArrayList<>();
PostRow cur = focusRow;
for (int i = 0; i < depthUp; i++) {
if (cur.toBlockNumber == null || cur.toBchName == null) break;
PostRow parent = findByNumber(c, cur.toBchName, cur.toBlockNumber);
if (cur.toBlockNumber == null) break;
PostRow parent;
if (cur.msgSubType == MsgSubType.TEXT_RATING
&& cur.toLogin != null && !cur.toLogin.isBlank()
&& cur.toBlockHash != null && cur.toBlockHash.length == 32) {
parent = findByLogicalTarget(c, cur.toLogin, cur.toBlockNumber, cur.toBlockHash);
} else {
// REPLY and other legacy thread links intentionally keep their existing physical-target behavior.
if (cur.toBchName == null) break;
parent = findByNumber(c, cur.toBchName, cur.toBlockNumber);
}
if (parent == null) break;
ancestors.add(0, toNode(c, parent, viewerLogin));
cur = parent;
@@ -102,18 +111,26 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
}
private List<PostRow> findRepliesAndRatings(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
String targetLogin = ChannelsReadSupport.loginFromChainName(toBchName);
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
SELECT login,bch_name,block_number,block_hash,block_bytes,to_login,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
FROM blocks
WHERE msg_type=1 AND msg_sub_type IN (?, ?)
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
WHERE msg_type=1
AND to_block_number=? AND to_block_hash=?
AND (
(msg_sub_type=? AND to_bch_name=?)
OR (msg_sub_type=? AND LOWER(to_login)=LOWER(?))
)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, MsgSubType.TEXT_REPLY);
ps.setInt(2, MsgSubType.TEXT_RATING);
ps.setString(3, toBchName);
ps.setInt(4, toBlockNumber);
ps.setBytes(5, toBlockHash);
ps.setInt(1, toBlockNumber);
ps.setBytes(2, toBlockHash);
// REPLY keeps its legacy physical-target lookup intentionally.
ps.setInt(3, MsgSubType.TEXT_REPLY);
ps.setString(4, toBchName);
// RATING follows the Stage 2 logical target identity.
ps.setInt(5, MsgSubType.TEXT_RATING);
ps.setString(6, targetLogin);
try (ResultSet rs = ps.executeQuery()) {
List<PostRow> out = new ArrayList<>();
while (rs.next()) out.add(mapRow(rs));
@@ -129,9 +146,27 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
}
}
private PostRow findByLogicalTarget(Connection c, String login, int blockNumber, byte[] blockHash) throws Exception {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_login,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
FROM blocks
WHERE LOWER(login)=LOWER(?) AND block_number=? AND block_hash=?
ORDER BY fork_number DESC
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, blockNumber);
ps.setBytes(3, blockHash);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
private PostRow findByNumber(Connection c, String bchName, int blockNumber) throws Exception {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
SELECT login,bch_name,block_number,block_hash,block_bytes,to_login,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
FROM blocks
WHERE bch_name=? AND block_number=?
LIMIT 1
@@ -147,7 +182,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
private PostRow findByNumberAndHash(Connection c, String bchName, int blockNumber, byte[] blockHash) throws Exception {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
SELECT login,bch_name,block_number,block_hash,block_bytes,to_login,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
FROM blocks
WHERE bch_name=? AND block_number=? AND block_hash=?
LIMIT 1
@@ -179,6 +214,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
row.blockNumber = rs.getInt("block_number");
row.blockHash = rs.getBytes("block_hash");
row.blockBytes = rs.getBytes("block_bytes");
row.toLogin = rs.getString("to_login");
row.toBchName = rs.getString("to_bch_name");
row.toBlockNumber = (Integer) rs.getObject("to_block_number");
row.toBlockHash = rs.getBytes("to_block_hash");
@@ -262,7 +298,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
private List<PostRow> findEdits(Connection c, String bch, int targetBlock, byte[] targetHash, int subType) throws Exception {
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
SELECT login,bch_name,block_number,block_hash,block_bytes,to_login,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
FROM blocks
WHERE bch_name=? AND msg_type=1 AND msg_sub_type=?
AND to_block_number=? AND to_block_hash=?
@@ -289,6 +325,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
int blockNumber;
byte[] blockHash;
byte[] blockBytes;
String toLogin;
String toBchName;
Integer toBlockNumber;
byte[] toBlockHash;
@@ -149,7 +149,7 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
item.setVersionsTotal(versions.size());
item.setText(versions.get(versions.size() - 1).getText());
fillTargetDetails(c, item, rs.getString("to_bch_name"),
fillTargetDetails(c, item, rs.getString("to_login"),
(Integer) rs.getObject("to_block_number"), rs.getBytes("to_block_hash"));
out.add(item);
}
@@ -210,25 +210,25 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
private void fillTargetDetails(Connection c,
Net_GetChannelMessages_Response.MessageItem item,
String targetBch,
String targetLogin,
Integer targetBlockNumber,
byte[] targetHash) throws Exception {
if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
if (targetLogin == null || targetLogin.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) {
return;
}
try (PreparedStatement ps = c.prepareStatement("""
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
to_login, to_bch_name, to_block_number, to_block_hash
FROM blocks
WHERE bch_name = ? AND block_number = ?
WHERE LOWER(login) = LOWER(?) AND block_number = ? AND block_hash = ?
ORDER BY fork_number DESC
LIMIT 1
""")) {
ps.setString(1, targetBch);
ps.setString(1, targetLogin);
ps.setInt(2, targetBlockNumber);
ps.setBytes(3, targetHash);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return;
byte[] actualHash = rs.getBytes("block_hash");
if (actualHash == null || !java.util.Arrays.equals(actualHash, targetHash)) return;
item.setTargetMsgSubType(rs.getInt("msg_sub_type"));
item.setTargetAuthorLogin(rs.getString("login"));
item.setTargetAuthorBlockchainName(rs.getString("bch_name"));
@@ -145,10 +145,13 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
private List<ChannelKey> loadFollowedChannels(Connection c, String canonicalLogin, boolean onlyUserRoots) throws Exception {
List<ChannelKey> out = new ArrayList<>();
String sql = """
SELECT cs.to_login, cs.to_bch_name, COALESCE(cs.to_block_number,0) AS root_number, cs.to_block_hash
SELECT cs.to_login,
COALESCE(cn.owner_bch_name,(SELECT bs.blockchain_name FROM blockchain_state bs WHERE LOWER(bs.login)=LOWER(cs.to_login) ORDER BY bs.fork_number DESC LIMIT 1)) AS to_bch_name,
COALESCE(cs.to_block_number,0) AS root_number, cs.to_block_hash
FROM connections_state cs
LEFT JOIN channel_names_state cn ON LOWER(cn.owner_login)=LOWER(cs.to_login) AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash
WHERE cs.login=? AND cs.rel_type=?
ORDER BY cs.to_login, cs.to_bch_name, root_number
ORDER BY cs.to_login, root_number
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, canonicalLogin);
@@ -48,13 +48,14 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
Integer expectedRoot = req.getChannel() != null ? req.getChannel().getChannelRootBlockNumber() : null;
String expectedOwnerBch = req.getChannel() != null ? req.getChannel().getOwnerBlockchainName() : null;
String expectedOwnerLogin = loginFromChainName(expectedOwnerBch);
boolean strictChannelMatch = expectedRoot != null && expectedRoot >= 0
&& expectedOwnerBch != null && !expectedOwnerBch.isBlank();
&& expectedOwnerLogin != null && !expectedOwnerLogin.isBlank();
String existsSql = """
SELECT 1
FROM blocks
WHERE bch_name = ?
WHERE LOWER(login) = LOWER(?)
AND block_number = ?
AND block_hash = ?
AND msg_type = ?
@@ -65,7 +66,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
String insertSql = """
INSERT INTO message_views_state (
viewer_login, to_bch_name, to_block_number, to_block_hash, first_seen_at_ms
viewer_login, to_login, to_block_number, to_block_hash, first_seen_at_ms
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING
""";
@@ -87,13 +88,15 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
if (bch.isBlank() || no == null || no < 0) continue;
if (!hashHex.matches("^[0-9a-f]{64}$")) continue;
if (strictChannelMatch && !bch.equals(expectedOwnerBch)) continue;
String targetLogin = loginFromChainName(bch);
if (targetLogin == null || targetLogin.isBlank()) continue;
if (strictChannelMatch && !targetLogin.equalsIgnoreCase(expectedOwnerLogin)) continue;
String key = bch + "|" + no + "|" + hashHex;
String key = targetLogin.toLowerCase(java.util.Locale.ROOT) + "|" + no + "|" + hashHex;
if (!dedup.add(key)) continue;
existsPs.clearParameters();
existsPs.setString(1, bch);
existsPs.setString(1, targetLogin);
existsPs.setInt(2, no);
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
@@ -117,7 +120,7 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
insertPs.clearParameters();
insertPs.setString(1, viewerLogin);
insertPs.setString(2, bch);
insertPs.setString(2, targetLogin);
insertPs.setInt(3, no);
insertPs.setBytes(4, ChannelsReadSupport.hexToBytes(hashHex));
insertPs.setLong(5, nowMs);
@@ -137,4 +140,6 @@ public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Internal server error");
}
}
private static String loginFromChainName(String bch) { if(bch==null)return null; int d=bch.lastIndexOf('-'); return d>0?bch.substring(0,d):bch; }
}
@@ -44,7 +44,11 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler {
// Idempotent insert for close-friend relation.
// Rely on PostgreSQL ON CONFLICT DO NOTHING.
insertCloseFriendIgnoreDuplicate(c, from, canonicalTo, targetBch);
byte[] targetHeaderHash = findHeaderHash(c, targetBch);
if (targetHeaderHash == null || targetHeaderHash.length != 32) {
return NetExceptionResponseFactory.error(req, 409, "HEADER_NOT_FOUND", "У целевого blockchain нет HEADER block 0");
}
insertCloseFriendIgnoreDuplicate(c, from, canonicalTo, targetBch, targetHeaderHash);
Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response();
resp.setOp(req.getOp());
@@ -78,25 +82,35 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler {
}
}
private byte[] findHeaderHash(Connection c, String blockchainName) throws Exception {
String sql = "SELECT block_hash FROM blocks WHERE bch_name = ? AND block_number = 0 LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, blockchainName);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getBytes("block_hash") : null;
}
}
}
private void insertCloseFriendIgnoreDuplicate(Connection c,
String login,
String toLogin,
String toBchName) throws Exception {
String toBchName,
byte[] targetHeaderHash) throws Exception {
String sql = """
INSERT INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
login, rel_type, to_login, to_block_number, to_block_hash
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash) DO NOTHING
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (login, rel_type, to_login, to_block_number, to_block_hash) DO NOTHING
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, MsgSubType.CONNECTION_CLOSE_FRIEND);
ps.setString(3, toLogin);
ps.setString(4, toBchName);
ps.setInt(5, 0);
ps.setBytes(6, new byte[32]);
ps.setInt(4, 0);
ps.setBytes(5, targetHeaderHash);
ps.executeUpdate();
}
}
@@ -59,7 +59,7 @@ final class KeyRotationApiSupport {
throw new IllegalArgumentException("bad blockchain suffix");
}
int n = Integer.parseInt(suffix);
if (n >= 999) throw new IllegalArgumentException("blockchain fork limit reached");
if (n < 1 || n >= 999) throw new IllegalArgumentException("blockchain fork limit reached");
return login + "-" + String.format("%03d", n + 1);
}
@@ -503,6 +503,7 @@ public final class ShineUsersCodec {
if (rootKey == null || clientKey == null || blockchainKey == null) {
throw new IllegalArgumentException("PDA 1.2 misses required blocks");
}
if (forkCount > 999) throw new IllegalArgumentException("PDA fork limit exceeded: max 999");
String blockchainName = normalizeLogin(login) + "-" + String.format("%03d", forkCount);
return new UserPdaSnapshot(
pdaAddress, login, recordNumber, slot, lastTxSignature,
@@ -119,9 +119,8 @@ public final class KeyRotationRebuildService {
}
});
// Чужие подписанные ссылки сохраняют логическую identity login+number+hash.
// Здесь меняется только их локальный cache физического fork.
cleanup.refreshLogicalTargetBlockchainCache(session.getLogin(), source, target);
// Чужие подписанные ссылки уже адресованы логически: login+number+hash.
// После replay никакого rebind/cache refresh для target-ссылок не требуется.
} finally {
if (targetGuard) BlockchainResyncGuard.end(target);
if (sourceGuard) BlockchainResyncGuard.end(source);
@@ -167,7 +167,8 @@ public final class AddBlockSender {
private static short typeOf(BodyRecord body) {
if (body instanceof HeaderBody) return HeaderBody.TYPE;
if (body instanceof CreateChannelBody) return CreateChannelBody.TYPE;
if (body instanceof TextBody) return TextBody.TYPE;
if (body instanceof TextLineBody || body instanceof TextReplyBody || body instanceof TextRatingBody) return TextLineBody.TYPE;
if (body instanceof StatusActionBody) return StatusActionBody.TYPE;
if (body instanceof ReactionBody) return ReactionBody.TYPE;
if (body instanceof ConnectionBody) return ConnectionBody.TYPE;
if (body instanceof UserParamBody) return UserParamBody.TYPE;
@@ -177,7 +178,10 @@ public final class AddBlockSender {
private static short subTypeOf(BodyRecord body) {
if (body instanceof HeaderBody hb) return hb.subType;
if (body instanceof CreateChannelBody cb) return cb.subType;
if (body instanceof TextBody tb) return tb.subType;
if (body instanceof TextLineBody tb) return tb.subType;
if (body instanceof TextReplyBody tb) return tb.subType;
if (body instanceof TextRatingBody tb) return tb.subType;
if (body instanceof StatusActionBody sb) return sb.subType;
if (body instanceof ReactionBody rb) return rb.subType;
if (body instanceof ConnectionBody cb) return cb.subType;
if (body instanceof UserParamBody ub) return ub.subType;
@@ -187,7 +191,10 @@ public final class AddBlockSender {
private static short versionOf(BodyRecord body) {
if (body instanceof HeaderBody hb) return hb.version;
if (body instanceof CreateChannelBody cb) return cb.version;
if (body instanceof TextBody tb) return tb.version;
if (body instanceof TextLineBody tb) return tb.version;
if (body instanceof TextReplyBody tb) return tb.version;
if (body instanceof TextRatingBody tb) return tb.version;
if (body instanceof StatusActionBody sb) return sb.version;
if (body instanceof ReactionBody rb) return rb.version;
if (body instanceof ConnectionBody cb) return cb.version;
if (body instanceof UserParamBody ub) return ub.version;
@@ -200,7 +207,7 @@ public final class AddBlockSender {
if (body instanceof CreateChannelBody ccb) {
return shine.db.channels.ChannelNameRules.toCanonicalSlug(ccb.channelName);
}
if (body instanceof TextBody tb && tb.isLineMessage() && tb.lineCode > 0) {
if (body instanceof TextLineBody tb && tb.lineCode > 0) {
String slug = channelSlugByRoot.get(tb.lineCode);
if (slug == null) {
throw new IllegalStateException("Unknown channel slug for lineCode=" + tb.lineCode);
@@ -3,7 +3,7 @@ package test.it.blockchain;
import blockchain.body.BodyRecord;
import blockchain.body.BodyHasLine;
import blockchain.body.CreateChannelBody;
import blockchain.body.TextBody;
import blockchain.body.TextLineBody;
import java.util.HashMap;
import java.util.Map;
@@ -305,8 +305,8 @@ public final class ChainState {
}
// ---- TEXT channels (POST/EDIT_POST) ----
if (t == TYPE_TEXT && body instanceof TextBody tb) {
if (tb.isLineMessage()) {
if (t == TYPE_TEXT && body instanceof TextLineBody tb) {
{
int lineCode = tb.lineCode;
ChannelLineState channel = textChannels.get(lineCode);
@@ -15,6 +15,7 @@ import test.it.utils.log.TestResult;
import test.it.utils.ws.WsSession;
import java.time.Duration;
import utils.crypto.Ed25519Util;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
@@ -28,7 +29,7 @@ import static org.junit.jupiter.api.Assertions.*;
* CONNECTION (type=3):
* - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber)
* - всегда имеет target:
* toLogin + toBlockGlobalNumber + toBlockHash32
* toLogin + toForkNumber + toBlockGlobalNumber + toBlockHash32
*
* Правило target для связей/подписок:
* - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0)
@@ -76,7 +77,7 @@ public class IT_03_AddBlock_NoAuth {
ChainState st1 = new ChainState();
AddBlockSender sender1 = new AddBlockSender(ws, st1, u1, bch1, TestConfig.getBlockchainPrivatKey(u1));
sender1.send(new HeaderBody(u1), t);
sender1.send(new HeaderBody(u1, Ed25519Util.derivePublicKey(TestConfig.getBlockchainPrivatKey(u1))), t);
assertTrue(st1.hasHeader());
int u1HeaderBlock = 0;
@@ -93,7 +94,7 @@ public class IT_03_AddBlock_NoAuth {
root0,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.TEXT_POST,
null, null, null,
null, null, null, null,
"U1: story/post in channel 0"
), t);
}
@@ -153,7 +154,7 @@ public class IT_03_AddBlock_NoAuth {
newsRootBlock,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.TEXT_POST,
null, null, null,
null, null, null, null,
"U1: News post #0"
), t);
@@ -169,21 +170,21 @@ public class IT_03_AddBlock_NoAuth {
newsRootBlock,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.TEXT_POST,
null, null, null,
null, null, null, null,
"U1: News post #1"
), t);
}
// EDIT_POST -> target на ОРИГИНАЛЬНЫЙ POST по login + number + hash
// EDIT_POST -> compact target на собственный оригинальный POST: number + hash
{
var ln = st1.nextTextLineByRoot(newsRootBlock);
sender1.send(new TextLineBody(
newsRootBlock,
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
MsgSubType.TEXT_EDIT_POST,
null, null,
newsPost0Block,
newsPost0Hash,
u1,
"U1: News post #0 (EDIT)"
), t);
}
@@ -194,7 +195,7 @@ public class IT_03_AddBlock_NoAuth {
ChainState st2 = new ChainState();
AddBlockSender sender2 = new AddBlockSender(ws, st2, u2, bch2, TestConfig.getBlockchainPrivatKey(u2));
sender2.send(new HeaderBody(u2), t);
sender2.send(new HeaderBody(u2, Ed25519Util.derivePublicKey(TestConfig.getBlockchainPrivatKey(u2))), t);
assertTrue(st2.hasHeader());
int u2HeaderBlock = 0;
@@ -253,9 +254,9 @@ public class IT_03_AddBlock_NoAuth {
{
sender2.send(new TextReplyBody(
MsgSubType.TEXT_REPLY,
u1, 1,
newsPost0Block,
newsPost0Hash,
u1,
"U2: reply to U1 News post #0 (cross-chain)"
), t);
}
@@ -266,7 +267,7 @@ public class IT_03_AddBlock_NoAuth {
ChainState st3 = new ChainState();
AddBlockSender sender3 = new AddBlockSender(ws, st3, u3, bch3, TestConfig.getBlockchainPrivatKey(u3));
sender3.send(new HeaderBody(u3), t);
sender3.send(new HeaderBody(u3, Ed25519Util.derivePublicKey(TestConfig.getBlockchainPrivatKey(u3))), t);
assertTrue(st3.hasHeader());
int u3HeaderBlock = 0;
@@ -340,6 +341,7 @@ public class IT_03_AddBlock_NoAuth {
ln.thisLineNumber,
subType,
toLogin,
1,
toBlockNumber,
toBlockHash32
), timeout);
@@ -3,7 +3,7 @@ package test.it.cases;
import blockchain.MsgSubType;
import blockchain.body.ConnectionBody;
import blockchain.body.HeaderBody;
import blockchain.body.TextBody;
import blockchain.body.TextLineBody;
import blockchain.body.UserParamBody;
import test.it.blockchain.AddBlockSender;
import test.it.blockchain.ChainState;
@@ -112,11 +112,13 @@ public final class SeedDataPopulationHelper {
AddBlockSender sender = senderFor(login);
ChainState state = stateFor(login);
ChainState.NextLine line = state.nextTextLineByCode(0);
sender.send(TextBody.newPost(
sender.send(new TextLineBody(
line.lineCode,
line.prevLineNumber,
line.prevLineHash32,
line.thisLineNumber,
MsgSubType.TEXT_POST,
null, null, null, null,
cleanText
), timeout);
}
@@ -152,6 +154,7 @@ public final class SeedDataPopulationHelper {
line.thisLineNumber,
relationSubType,
to,
1,
0,
targetHeaderHash
), timeout);
@@ -160,7 +163,7 @@ public final class SeedDataPopulationHelper {
private void initHeader(String login) {
ChainState state = new ChainState();
AddBlockSender sender = new AddBlockSender(ws, state, login, bch(login), keys.blockchainPrivate32);
sender.send(new HeaderBody(login), timeout);
sender.send(new HeaderBody(login, Ed25519Util.derivePublicKey(keys.blockchainPrivate32)), timeout);
byte[] headerHash = state.getHash32(0);
if (headerHash == null || headerHash.length != 32) {
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.14.1
server.version=1.12.0
client.version=1.14.2
server.version=1.12.1
+4 -2
View File
@@ -56,10 +56,12 @@ psql \
-f SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql
```
Скрипт предназначен **только для полностью пустой PostgreSQL схемы**. На старой/непустой базе он завершится ошибкой и не выполняет in-place migration.
Скрипт:
- создаёт таблицу версии схемы `db_schema_version`;
- ставит актуальный `schema_version`;
- создаёт жёсткий маркер поколения БД `shine_database_metadata`: `SHiNE_DB_V2`, generation `2`;
- создаёт таблицу версии runtime-схемы `db_schema_version` и ставит `schema_version=29`;
- создаёт таблицы sync-модуля Solana users;
- создаёт server runtime tables;
- создаёт триггеры и функции автоматической актуализации `user_access_servers_current`;
+5 -2
View File
@@ -1,5 +1,8 @@
# Key Rotation API
> Протокольная семантика fork и target-ссылок: `docs/Blockchain/18_KEY_ROTATION_AND_FORK_TARGETS.md`.
> Физические fork имеют номера только `001..999`; из `*-999` следующая ротация blockchain key запрещена.
Этот раздел описывает публичные JSON/WebSocket операции мастера смены ключей пользователя.
Публично доступны:
@@ -274,8 +277,8 @@
- проверяется, что current PDA действительно указывает на `candidateBlockchainName/newBlockchainKey`;
- старая активная цепочка удаляется из рабочих PostgreSQL-таблиц;
- candidate-блоки повторно проходят обычный `AddBlock` validation/projection path;
- runtime-cache `to_bch_name` у логических ссылок `login + blockNumber + blockHash` перепривязывается к новому fork;
- входящие `likes_count/replies_count` пересчитываются;
- никакого rebind `to_bch_name` не выполняется: внешняя цель уже идентифицируется как `login + blockNumber + blockHash`;
- входящие `message_stats` не удаляются; для активных LIKE удаляемого пользователя точечно пересчитываются только затронутые like-счётчики; REPLY в Stage 2 отдельно не перерабатывается;
- после `COMPLETE` временные строки candidate-chain удаляются из PostgreSQL.
Если rebuild прерывается, `REBUILDING_SERVER` остаётся активным, ошибка записывается в `lastError/retryCount`, а worker безопасно повторяет rebuild.
+1 -1
View File
@@ -40,7 +40,7 @@ Payload содержит:
- `blockHash` / `prevBlockHash`;
- `timestampMs`;
- `msgType` / `msgSubType` / `msgVersion`;
- для target-блоков: `toLogin + toBlockNumber + toBlockHash`;
- для target-блоков: `toLogin + toForkNumber + toBlockNumber + toBlockHash` (для compact EDIT `toLogin/toForkNumber` отсутствуют);
- `blockBytesB64`, только если запрошен `includeBlockBytes=true`.
После fork API показывает только новую активную ветку PostgreSQL. Исторические fork при необходимости восстанавливаются из Arweave/PDA history отдельным будущим viewer-механизмом.
@@ -114,3 +114,10 @@ Slug входит в подпись DataItem и не может быть изм
7. проверить `blockNumber == last + 1`;
8. проверить `prevHash32 == lastBlockHash`;
9. записать DataItem и новое состояние атомарно в PostgreSQL.
## Нормативное правило версий body
После запуска layout существующей тройки `(type, subType, version)` неизменяем. Любое изменение порядка, размера или смысла подписываемых байтов требует новой `version`. В текущем чистом запуске используется только новый канонический layout; legacy-body не поддерживаются.
Для внешних target-полей fork является исторической меткой, а не частью логической identity. Identity определяется `toLogin + toBlockGlobalNumber + toBlockHash32`.
@@ -5,6 +5,7 @@
Базовый идентификатор цепочки пользователя:
- `blockchainName = <login>-<NNN>`
- `NNN` — реальный номер fork строго `001..999`; `000` и `1000+` недопустимы;
- пример: `alice-001`
Обычно это одна основная цепочка пользователя.
+2 -1
View File
@@ -6,7 +6,8 @@ TECH-тип покрывает системные записи цепочки.
1. `subType=0` — `HEADER_COMPAT`
- стартовый блок цепочки;
- payload: tag `SHiNE` + login владельца.
- payload: tag `SHiNE` + login владельца + `initialBlockchainKey32`;
- `initialBlockchainKey32` — public blockchain-signing key, которым был создан fork №1; это историческая точка происхождения и при последующих fork сохраняется в скопированном HEADER байт-в-байт.
2. `subType=1` — `TECH_CREATE_CHANNEL`
- создание нового канала;
+17 -7
View File
@@ -10,20 +10,20 @@ TEXT-тип хранит сообщения, материалы и редакт
2. `subType=11` — `TEXT_EDIT_POST`
- редактирование поста;
- line-поля + target на оригинальный POST + новый текст.
- line-поля + compact target (`toBlockGlobalNumber`, `toBlockHash32`) на оригинальный POST + новый текст.
3. `subType=20` — `TEXT_REPLY`
- ответ на сообщение;
- target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
- target (`toLogin`, `toForkNumber`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
4. `subType=21` — `TEXT_EDIT_REPLY`
- редактирование ответа;
- target на исходный REPLY + новый текст.
- compact target (`toBlockGlobalNumber`, `toBlockHash32`) на исходный REPLY + новый текст.
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
5. `subType=30` — `TEXT_RATING`
- target-based отзыв на конкретный блок;
- содержит target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
- содержит target (`toLogin`, `toForkNumber`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
- не является сообщением линии канала.
6. `subType=50` — `TEXT_REPOST`
@@ -58,18 +58,28 @@ TEXT-тип хранит сообщения, материалы и редакт
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
## Общий target-формат TEXT
## Канонический target-формат TEXT
Для `TEXT_EDIT_POST`, `TEXT_REPLY`, `TEXT_EDIT_REPLY`, `TEXT_RATING` и `TEXT_REPOST` ссылка на цель хранится как:
Внешние цели (`TEXT_REPLY`, `TEXT_RATING`, `TEXT_REPOST`) используют:
```text
[1] toLoginLen (uint8)
[N] toLogin UTF-8
[2] toForkNumber (uint16, BigEndian)
[4] toBlockGlobalNumber
[32] toBlockHash32
```
`blockchainName`/номер fork в подписываемые байты target не входит. Одинаковые `login + blockNumber + blockHash` считаются одной логической целью после перепубликации сохранённого префикса при fork.
`toForkNumber`: `1..999` — fork, который видел автор действия; `0` зарезервирован как unknown/legacy. Поле информационное и **не входит в логическую идентичность цели**. Цель определяется только `toLogin + toBlockGlobalNumber + toBlockHash32`; несовпадение текущего fork само по себе не инвалидирует ссылку.
Собственные edit (`TEXT_EDIT_POST`, `TEXT_EDIT_REPLY`) используют компактный target:
```text
[4] toBlockGlobalNumber
[32] toBlockHash32
```
Login/fork для edit не подписываются: edit может ссылаться только на собственный блок текущей цепочки.
## Правило для edit
+4 -5
View File
@@ -4,10 +4,10 @@
1. `subType=1` — `REACTION_LIKE`
- лайк на целевой блок;
- хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
- хранит target: `toLogin`, `toForkNumber`, `toBlockGlobalNumber`, `toBlockHash32`.
2. `subType=2` — `REACTION_UNLIKE`
- снятие лайка с целевого блока;
- хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
- хранит target: `toLogin`, `toForkNumber`, `toBlockGlobalNumber`, `toBlockHash32`.
## Назначение
@@ -16,13 +16,12 @@
## Формат target
В подписанных байтах target больше не хранит имя fork/blockchain. Формат:
```text
[1] toLoginLen (uint8)
[N] toLogin UTF-8
[2] toForkNumber (uint16, BigEndian)
[4] toBlockGlobalNumber
[32] toBlockHash32
```
Логическая идентичность цели: `toLogin + toBlockGlobalNumber + toBlockHash32`. Поэтому ссылка остаётся той же после fork, если номер и hash исходного блока сохранены.
`toForkNumber` — только историческая метка (`1..999`, `0` reserved unknown/legacy). Логическая идентичность реакции: `toLogin + toBlockGlobalNumber + toBlockHash32`. При fork номер fork может измениться; если number+hash сохранены в новой ветке, LIKE/UNLIKE продолжает указывать на тот же логический блок.
+3 -2
View File
@@ -18,18 +18,19 @@ CONNECTION-тип описывает социальные связи и подп
## Общий формат payload
- line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`)
- target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`)
- target (`toLogin`, `toForkNumber`, `toBlockGlobalNumber`, `toBlockHash32`)
## Бинарный target
```text
[1] toLoginLen (uint8)
[N] toLogin UTF-8
[2] toForkNumber (uint16, BigEndian)
[4] toBlockGlobalNumber
[32] toBlockHash32
```
Имя fork/blockchain в target не хранится.
`toForkNumber` хранит fork, который видел автор связи (`1..999`), но не участвует в identity. `0` зарезервирован как unknown/legacy; `1000+` недопустим. Для связи на пользователя используется **реальный HEADER hash**: `toBlockGlobalNumber=0`, `toBlockHash32=SHA-256(Frame HEADER)`. Нулевой hash запрещён.
## Правила target
+3 -1
View File
@@ -38,6 +38,7 @@
```text
[1] toLoginLen (uint8)
[N] toLogin UTF-8
[2] toForkNumber uint16 (BigEndian)
[4] toBlockGlobalNumber
[32] toBlockHash32
[2] textLenBytes (uint16)
@@ -46,7 +47,8 @@
Где:
- `toLogin` — login владельца целевого блока; номер fork в target не хранится;
- `toLogin` — login владельца целевого блока;
- `toForkNumber` — историческая метка fork (`1..999`, `0` reserved unknown/legacy), не часть identity;
- `toBlockGlobalNumber` — номер целевого блока;
- `toBlockHash32` — хэш целевого блока;
- `text` — опциональное пояснение пользователя к статусу.
@@ -0,0 +1,77 @@
# Key rotation, fork и логические ссылки
Этот документ фиксирует протокольную семантику fork. API-последовательность ротации описана отдельно в `docs/API/19_Key_Rotation_API.md`.
## 1. Идентичности
- Пользователь: `login`.
- Физическая ветка: `login-forkNumber` (`1..999`, формат имени строго три цифры: `001..999`; fork `1000` и выше запрещён).
- Активный fork определяется текущим PDA.
- Исторические fork остаются проверяемыми.
## 2. HEADER
Block 0 новой пользовательской истории — `HEADER`:
```text
SHiNE + login + initialBlockchainKey32
```
`initialBlockchainKey32` — public blockchain-signing key fork №1. При смене ключа сохранённый префикс, включая HEADER, перепубликуется с тем же Frame; поэтому это поле остаётся исходным ключом, а новый ключ конкретного fork определяется подписью/owner и PDA.
## 3. Внешний target
Для REPLY, RATING, REPOST, LIKE/UNLIKE, CONNECTION, STATUS_ACTION:
```text
[1] toLoginLen
[N] toLogin UTF-8
[2] toForkNumber uint16
[4] toBlockGlobalNumber
[32] toBlockHash32
```
`toForkNumber` — информационная историческая метка: fork, на который смотрел автор действия. `0` зарезервирован как unknown/legacy и не является реальным fork. Поле не участвует в identity и не должно сравниваться с текущим active fork как условие валидности.
Логическая identity цели:
```text
toLogin + toBlockGlobalNumber + toBlockHash32
```
Если сохранённый префикс перепубликован в новом fork байт-в-байт, номер блока и SHA-256(Frame) совпадают, поэтому внешняя ссылка продолжает указывать на тот же логический блок.
## 4. Собственный edit
EDIT_POST и EDIT_REPLY относятся только к собственному blockchain и используют:
```text
[4] toBlockGlobalNumber
[32] toBlockHash32
```
Login/fork не хранятся. Целевой оригинальный блок должен существовать в текущей ветке с тем же hash. Блок, отброшенный rollback и отсутствующий в новом active fork, редактировать из новой ветки нельзя.
## 5. CONNECTION на пользователя
Связь на пользователя всегда указывает на его реальный HEADER:
```text
toLogin = target login
toBlockGlobalNumber = 0
toBlockHash32 = SHA-256(target HEADER Frame)
```
Нулевой hash как sentinel запрещён.
## 6. Что переносится при fork
Для сохранённого префикса Frame не меняется. Могут измениться внешняя ANS-104 подпись, owner/DataItem ID, но SHiNE block hash (`SHA-256(Frame)`) остаётся прежним. Поэтому `number + hash` является устойчивой частью ссылки.
`toForkNumber` старого действия не переписывается после fork: это исторический факт момента создания действия.
## 7. Серверная модель Stage 2
Stage 2 больше не хранит physical target name как identity. `blocks_store` хранит физическую ветку как `(login, fork_number)`, а `reactions_state`, `message_stats`, `connections_state` и просмотры адресуют цель логически через `login + blockNumber + hash`. Поэтому при смене active fork никакого массового rebind `old_bch_name -> new_bch_name` не выполняется.
Если `number + hash` сохранились после fork, внешние состояния продолжают относиться к той же цели автоматически. Если hash изменился после rollback, это новая логическая цель.
@@ -0,0 +1,62 @@
# Stage 2 — logical targets and numeric fork storage
## Canonical identities
Physical fork identity is `(login, fork_number)`, where `fork_number` is strictly `1..999`. `login-NNN` is a derived presentation value only; `1000+` is invalid.
A logical external target is `(to_login, to_block_number, to_block_hash)`. `to_fork_number` remains signed historical metadata but MUST NOT participate in equality, validity, LIKE aggregation, CONNECTION identity, or fork rebinding.
## Tables
- `blocks_store`: stores `login + fork_number`; does not store `bch_name` or `to_bch_name`.
- `blockchain_state_store`: keyed by `(login,fork_number)`.
- compatibility SQL views `blocks` and `blockchain_state` derive `login-NNN` for old read/API paths; the name is not persisted.
- `reactions_state`: current actor LIKE/UNLIKE state keyed by actor login + logical target.
- `message_stats`: logical target counters. It may exist before the target block is locally available.
- `connections_state`: logical targets only.
- `message_views_state`: viewer + logical target.
## LIKE before target
A valid LIKE is projected even when the target block has not arrived. `message_stats` is created for the logical target. When the target later arrives, it reads the already existing counters by `login + blockNumber + hash`.
## Fork behaviour
If a prefix is republished unchanged, its Frame hash remains unchanged, so incoming social state is automatically reused. No rebind is performed.
If rollback replaces a block with the same number but a different hash, the new block has separate stats. Old stats may remain as historical/orphaned state and do not attach to the replacement.
## Atomic cleanup before replay
`REBUILDING_SERVER` is set before cleanup/replay. Cleanup is one SQL transaction:
1. collect active LIKE targets of the actor;
2. remove actor rows from `reactions_state`;
3. recount only affected LIKE counters (all/official/shining);
4. remove actor outgoing `connections_state`, refresh affected target-user/channel stats, and reset actor-derived profile/state;
5. delete physical blocks for `(login,fork_number)`;
6. delete physical blockchain state for `(login,fork_number)`;
7. commit.
Incoming `message_stats` for the user's targets are never deleted. Candidate blocks are then replayed normally. A crash leaves rotation in `REBUILDING_SERVER`, so rebuild can be retried.
## REPLY
Stage 2 does not redesign REPLY. Only the unavoidable database-column adaptation uses the logical target key after `to_bch_name` removal; no new REPLY UI/resolver/cleanup behaviour is introduced.
## EDIT
EDIT remains own-chain only. EDIT blocks disappear with their physical fork and return through replay. `edits_count` is not part of Stage 2 message aggregates.
## Database bootstrap / DB v2 marker
Stage 2 uses a new database generation explicitly marked as **`SHiNE_DB_V2`** (`database_generation=2`) in `shine_database_metadata`. The runtime schema revision is `29`.
Stage 2 is installed only on a clean PostgreSQL schema. The final schema is created directly by `schema_v1.sql`; there is no `migration_v29.sql`. Both the server bootstrapper and the SQL bootstrap script reject a non-empty/legacy database instead of modifying or migrating it.
At every server start, both conditions are required:
- `shine_database_metadata.id=1`, `database_format='SHiNE_DB_V2'`, `database_generation=2`;
- `db_schema_version.id=1`, `schema_version=29`.
If an old SHiNE database is configured accidentally, server startup fails with an explicit legacy/non-v2 database error.
+2
View File
@@ -46,6 +46,8 @@
- `username-002` - вторая ветка;
- `username-003` - третья ветка.
Номер fork ограничен диапазоном `001..999`; после `username-999` новая ротация blockchain key не создаётся.
Рабочая логика по умолчанию должна использовать последнюю актуальную ветку. Старые ветки остаются читаемыми и показывают историю смены ключей.
## `client key`
@@ -579,7 +579,7 @@ SYNC_POLL_INTERVAL_SECONDS=300
- `ServerProfileBlock` в 1.2 допускает один адрес сервера, `AccessServersBlock` — 0 или 1 access server;
- в compatibility SQL-поля проецируется **последний** fork как активный `blockchain_key/paid_limit_bytes`;
- полный список fork сохраняется в `blockchain_forks_json`, поэтому поиск пользователя по старому blockchain key остаётся возможным;
- `blockchain_name` для compatibility view вычисляется как `<normalized_login>-NNN`, где `NNN` соответствует индексу fork + 1;
- `blockchain_name` для compatibility view вычисляется как `<normalized_login>-NNN`, где `NNN` соответствует номеру fork `001..999`; PDA с `fork_count > 999` сервером не принимается;
- удалённые tip-поля (`used_bytes`, `last_block_*`, Arweave tx id) в PDA 1.2 больше не являются источником истины и в compatibility snapshot заполняются нейтральными значениями;
- server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес.
@@ -604,7 +604,7 @@ Create/update транзакции больше не реконструирую
### Candidate blocks ротации (PostgreSQL v27)
Начиная с migration v27 будущая ветка во время `COPYING_CHAIN` хранится в отдельной таблице `key_rotation_candidate_blocks`. Она не является частью текущего materialized blockchain state и не должна попадать в обычную `blocks` до финального переключения fork.
Будущая ветка во время `COPYING_CHAIN` хранится в отдельной таблице `key_rotation_candidate_blocks`. Она не является частью текущего materialized blockchain state и не должна попадать в обычную `blocks` до финального переключения fork.
Для каждого candidate DataItem сохраняются rotation session, login, candidate blockchain name, block number/hash, полный ANS-104 DataItem, DataItem id и статус публикации. Уникальность `(rotation_session_id, block_number)` запрещает две разные версии одного candidate-блока.
+172 -239
View File
@@ -50,6 +50,7 @@ const MSG_TYPE_REACTION = 2;
const MSG_TYPE_CONNECTION = 3;
const MSG_TYPE_STATUS_ACTION = 5;
const MSG_SUBTYPE_TECH_HEADER = 0;
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
@@ -534,6 +535,24 @@ function extractContactsFromDialogs(dialogs) {
return out;
}
function makeHeaderBodyBytes({ login, initialBlockchainKey32 }) {
const cleanLogin = String(login || '').trim();
const loginBytes = utf8Bytes(cleanLogin);
const keyBytes = initialBlockchainKey32 instanceof Uint8Array
? initialBlockchainKey32
: new Uint8Array(initialBlockchainKey32 || []);
if (loginBytes.length < 1 || loginBytes.length > 255) throw new Error('HEADER login must be 1..255 UTF-8 bytes');
if (keyBytes.length !== 32 || keyBytes.every((value) => value === 0)) {
throw new Error('HEADER initialBlockchainKey32 must be a non-zero 32-byte key');
}
return concatBytes(
utf8Bytes('SHiNE'),
int8Byte(loginBytes.length),
loginBytes,
keyBytes,
);
}
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
const keyBytes = utf8Bytes(String(key || ''));
const valueBytes = utf8Bytes(String(value || ''));
@@ -553,262 +572,124 @@ function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thi
);
}
function makeReactionLikeBodyBytes({ toLogin, toBlockNumber, toBlockHashHex }) {
function makeReactionLikeBodyBytes({ toLogin, toForkNumber, toBlockNumber, toBlockHashHex }) {
const cleanLogin = String(toLogin || '').trim();
if (!cleanLogin) throw new Error('toLogin is required for like');
const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for like');
}
const loginBytes = utf8Bytes(cleanLogin);
if (loginBytes.length < 1 || loginBytes.length > 255) {
throw new Error('toLogin must be 1..255 bytes');
}
return concatBytes(
int8Byte(loginBytes.length),
loginBytes,
int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex))
);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'like');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'like');
const loginBytes = targetLoginBytes(cleanLogin);
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'like');
return concatBytes(int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes);
}
function makeTextReplyBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text }) {
const cleanLogin = String(toLogin || '').trim();
if (!cleanLogin) throw new Error('toLogin is required for reply');
const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for reply');
}
const message = String(text || '').trim();
if (!message) throw new Error('Reply text is required');
const loginBytes = utf8Bytes(cleanLogin);
if (loginBytes.length < 1 || loginBytes.length > 255) {
throw new Error('toLogin must be 1..255 bytes');
}
const textBytes = utf8Bytes(message);
if (textBytes.length < 1 || textBytes.length > 65535) {
throw new Error('Reply text must be 1..65535 UTF-8 bytes');
}
return concatBytes(
int8Byte(loginBytes.length),
loginBytes,
int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length),
textBytes
);
}
function makeTextRatingBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text }) {
const cleanLogin = String(toLogin || '').trim();
if (!cleanLogin) throw new Error('toLogin is required for rating');
const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for rating');
}
const message = String(text || '').trim();
if (!message) throw new Error('Rating text is required');
const loginBytes = utf8Bytes(cleanLogin);
if (loginBytes.length < 1 || loginBytes.length > 255) {
throw new Error('toLogin must be 1..255 bytes');
}
const textBytes = utf8Bytes(message);
if (textBytes.length < 1 || textBytes.length > 65535) {
throw new Error('Rating text must be 1..65535 UTF-8 bytes');
}
return concatBytes(
int8Byte(loginBytes.length),
loginBytes,
int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length),
textBytes
);
}
function makeStatusActionBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text = '' }) {
const cleanLogin = String(toLogin || '').trim();
if (!cleanLogin) throw new Error('toLogin is required for status action');
const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for status action');
}
const loginBytes = utf8Bytes(cleanLogin);
if (loginBytes.length < 1 || loginBytes.length > 255) {
throw new Error('toLogin must be 1..255 bytes');
}
function makeTextReplyBodyBytes({ toLogin, toForkNumber, toBlockNumber, toBlockHashHex, text }) {
const loginBytes = targetLoginBytes(toLogin);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'reply');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'reply');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'reply');
const textBytes = utf8Bytes(String(text || '').trim());
if (textBytes.length > 65535) {
throw new Error('Status action text must be 0..65535 UTF-8 bytes');
}
if (textBytes.length < 1 || textBytes.length > 65535) throw new Error('Reply text must be 1..65535 UTF-8 bytes');
return concatBytes(int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes, int16Bytes(textBytes.length), textBytes);
}
return concatBytes(
int8Byte(loginBytes.length),
loginBytes,
int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length),
textBytes
);
function makeTextRatingBodyBytes({ toLogin, toForkNumber, toBlockNumber, toBlockHashHex, text }) {
const loginBytes = targetLoginBytes(toLogin);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'rating');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'rating');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'rating');
const textBytes = utf8Bytes(String(text || '').trim());
if (textBytes.length < 1 || textBytes.length > 65535) throw new Error('Rating text must be 1..65535 UTF-8 bytes');
return concatBytes(int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes, int16Bytes(textBytes.length), textBytes);
}
function makeStatusActionBodyBytes({ toLogin, toForkNumber, toBlockNumber, toBlockHashHex, text = '' }) {
const loginBytes = targetLoginBytes(toLogin);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'status action');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'status action');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'status action');
const textBytes = utf8Bytes(String(text || '').trim());
if (textBytes.length > 65535) throw new Error('Status action text must be 0..65535 UTF-8 bytes');
return concatBytes(int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes, int16Bytes(textBytes.length), textBytes);
}
function makeTextRepostBodyBytes({
lineCode,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
toLogin,
toBlockNumber,
toBlockHashHex,
text,
lineCode, prevLineNumber, prevLineHashHex, thisLineNumber,
toLogin, toForkNumber, toBlockNumber, toBlockHashHex, text,
}) {
const message = String(text || '').trim();
if (!message) throw new Error('Комментарий к репосту обязателен');
const cleanTargetLogin = String(toLogin || '').trim();
if (!cleanTargetLogin) throw new Error('toLogin is required for repost');
const loginBytes = utf8Bytes(cleanTargetLogin);
if (loginBytes.length < 1 || loginBytes.length > 255) {
throw new Error('toLogin must be 1..255 bytes');
}
const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for repost');
}
const loginBytes = targetLoginBytes(toLogin);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'repost');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'repost');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'repost');
const textBytes = utf8Bytes(message);
if (textBytes.length < 1 || textBytes.length > 65535) {
throw new Error('Repost comment must be 1..65535 UTF-8 bytes');
}
if (textBytes.length > 65535) throw new Error('Repost comment too long');
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int8Byte(loginBytes.length),
loginBytes,
int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length),
textBytes,
int32Bytes(lineCode), int32Bytes(prevLineNumber), hexToBytes(normalizeHex32(prevLineHashHex)), int32Bytes(thisLineNumber),
int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes,
int16Bytes(textBytes.length), textBytes,
);
}
function makeTextEditPostBodyBytes({
lineCode,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
toLogin,
toBlockNumber,
toBlockHashHex,
text,
lineCode, prevLineNumber, prevLineHashHex, thisLineNumber,
toBlockNumber, toBlockHashHex, text,
}) {
const message = String(text || '').trim();
const cleanTargetLogin = String(toLogin || '').trim();
if (!cleanTargetLogin) throw new Error('toLogin is required for edit post');
const loginBytes = utf8Bytes(cleanTargetLogin);
if (loginBytes.length < 1 || loginBytes.length > 255) throw new Error('toLogin must be 1..255 bytes');
const targetBlockNumber = Number(toBlockNumber);
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
throw new Error('Invalid target block number for edit post');
}
const textBytes = utf8Bytes(message);
if (textBytes.length > 65535) {
throw new Error('Message text must be 0..65535 UTF-8 bytes');
}
const targetBlockNumber = normalizeTargetBlockNumber(toBlockNumber, 'edit post');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'edit post');
const textBytes = utf8Bytes(String(text || '').trim());
if (textBytes.length > 65535) throw new Error('Message text must be 0..65535 UTF-8 bytes');
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int8Byte(loginBytes.length),
loginBytes,
int32Bytes(targetBlockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length),
textBytes,
int32Bytes(lineCode), int32Bytes(prevLineNumber), hexToBytes(normalizeHex32(prevLineHashHex)), int32Bytes(thisLineNumber),
int32Bytes(targetBlockNumber), hashBytes, int16Bytes(textBytes.length), textBytes,
);
}
function makeTextEditReplyBodyBytes({ toLogin, toBlockNumber, toBlockHashHex, text }) {
const message = String(text || '').trim();
const cleanTargetLogin = String(toLogin || '').trim();
if (!cleanTargetLogin) throw new Error('toLogin is required for edit reply');
const loginBytes = utf8Bytes(cleanTargetLogin);
if (loginBytes.length < 1 || loginBytes.length > 255) throw new Error('toLogin must be 1..255 bytes');
const targetBlockNumber = Number(toBlockNumber);
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
throw new Error('Invalid target block number for edit reply');
}
const textBytes = utf8Bytes(message);
if (textBytes.length > 65535) {
throw new Error('Message text must be 0..65535 UTF-8 bytes');
}
return concatBytes(
int8Byte(loginBytes.length),
loginBytes,
int32Bytes(targetBlockNumber),
hexToBytes(normalizeHex32(toBlockHashHex)),
int16Bytes(textBytes.length),
textBytes,
);
function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
const targetBlockNumber = normalizeTargetBlockNumber(toBlockNumber, 'edit reply');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'edit reply');
const textBytes = utf8Bytes(String(text || '').trim());
if (textBytes.length > 65535) throw new Error('Message text must be 0..65535 UTF-8 bytes');
return concatBytes(int32Bytes(targetBlockNumber), hashBytes, int16Bytes(textBytes.length), textBytes);
}
function makeConnectionBodyBytes({
lineCode = 0,
prevLineNumber = -1,
prevLineHashHex = ZERO64,
thisLineNumber = -1,
toLogin,
toBlockNumber,
toBlockHashHex,
lineCode = 0, prevLineNumber = -1, prevLineHashHex = ZERO64, thisLineNumber = -1,
toLogin, toForkNumber, toBlockNumber, toBlockHashHex,
}) {
const cleanTargetLogin = String(toLogin || '').trim();
if (!cleanTargetLogin) throw new Error('toLogin is required for connection');
const blockNumber = Number(toBlockNumber);
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
throw new Error('Invalid toBlockNumber for connection');
}
const loginBytes = utf8Bytes(cleanTargetLogin);
if (loginBytes.length < 1 || loginBytes.length > 255) {
throw new Error('toLogin must be 1..255 bytes');
}
const loginBytes = targetLoginBytes(toLogin);
const forkNumber = normalizeTargetForkNumber(toForkNumber, 'connection');
const blockNumber = normalizeTargetBlockNumber(toBlockNumber, 'connection');
const hashBytes = nonZeroHash32Bytes(toBlockHashHex, 'connection');
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int8Byte(loginBytes.length),
loginBytes,
int32Bytes(blockNumber),
hexToBytes(normalizeHex32(toBlockHashHex))
int32Bytes(lineCode), int32Bytes(prevLineNumber), hexToBytes(normalizeHex32(prevLineHashHex)), int32Bytes(thisLineNumber),
int8Byte(loginBytes.length), loginBytes, int16Bytes(forkNumber), int32Bytes(blockNumber), hashBytes,
);
}
function targetLoginBytes(value) {
const bytes = utf8Bytes(String(value || '').trim());
if (bytes.length < 1 || bytes.length > 255) throw new Error('toLogin must be 1..255 UTF-8 bytes');
return bytes;
}
function normalizeTargetForkNumber(value, actionName = 'target') {
const n = Number(value);
if (!Number.isInteger(n) || n < 0 || n > 999) throw new Error(`Invalid toForkNumber for ${actionName}`);
return n;
}
function normalizeTargetBlockNumber(value, actionName = 'target') {
const n = Number(value);
if (!Number.isInteger(n) || n < 0) throw new Error(`Invalid toBlockNumber for ${actionName}`);
return n;
}
function nonZeroHash32Bytes(value, actionName = 'target') {
const hex = normalizeHex32(value, ZERO64);
if (hex === ZERO64) throw new Error(`Zero target hash is forbidden for ${actionName}`);
return hexToBytes(hex);
}
function normalizeChannelDescription(value) {
const text = String(value == null ? '' : value).trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const bytes = utf8Bytes(text);
@@ -953,10 +834,18 @@ function makeTextLineBodyBytesAllowEmpty({ lineCode, prevLineNumber, prevLineHas
function loginFromBlockchainNameValue(value) {
const name = String(value || '').trim();
const match = name.match(/^(.*)-\d{3}$/u);
const match = name.match(/^(.*)-(\d{3})$/u);
return match?.[1] ? match[1] : '';
}
function forkNumberFromBlockchainNameValue(value) {
const name = String(value || '').trim();
const match = name.match(/^(.*)-(\d{3})$/u);
if (!match) return 0;
const forkNumber = Number(match[2]);
return Number.isInteger(forkNumber) && forkNumber >= 1 && forkNumber <= 999 ? forkNumber : 0;
}
function normalizeMessageRefTarget(target, actionName = 'action') {
const cleanBch = String(target?.blockchainName || target?.authorBlockchainName || '').trim();
const cleanLogin = String(target?.login || target?.authorLogin || target?.ownerLogin || loginFromBlockchainNameValue(cleanBch)).trim();
@@ -973,9 +862,15 @@ function normalizeMessageRefTarget(target, actionName = 'action') {
throw new Error(`Invalid message target hash for ${actionName}`);
}
const forkNumber = Number(target?.forkNumber ?? target?.toForkNumber ?? forkNumberFromBlockchainNameValue(cleanBch));
if (!Number.isInteger(forkNumber) || forkNumber < 1 || forkNumber > 999) {
throw new Error(`Missing/invalid message target fork for ${actionName}`);
}
return {
login: cleanLogin,
blockchainName: cleanBch,
forkNumber,
blockNumber: cleanBlockNumber,
blockHash: cleanBlockHash,
};
@@ -1839,11 +1734,16 @@ export class AuthService {
};
}
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes, channelSlug = '' }) {
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes, channelSlug = '', _skipHeaderEnsure = false }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login for AddBlock');
if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');
const isHeader = Number(msgType) === MSG_TYPE_TECH && Number(msgSubType) === MSG_SUBTYPE_TECH_HEADER;
if (!_skipHeaderEnsure && !isHeader) {
await this.ensureBlockchainHeader(cleanLogin, storagePwd);
}
const keyBundle = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const blockchainPrivatePkcs8 = String(keyBundle?.blockchainKey || '').trim();
if (!blockchainPrivatePkcs8) {
@@ -1888,22 +1788,43 @@ export class AuthService {
return payload;
}
async ensureChainInitializedForLineOps(login, storagePwd) {
const current = await this.getUser(login);
async ensureBlockchainHeader(login, storagePwd) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Missing login for HEADER bootstrap');
const current = await this.getUser(cleanLogin);
const lastNum = Number(current?.serverLastGlobalNumber);
if (Number.isFinite(lastNum) && lastNum >= 0) return current;
if (!(Number.isFinite(lastNum) && lastNum === -1)) return current;
// Bootstrap an empty chain with a minimal USER_PARAM block so line-based
// channel operations have a valid anchor at block #0.
await this.addBlockUserParam({
login,
const keyBundle = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
const privatePkcs8 = String(keyBundle?.blockchainKey || '').trim();
let publicKeyB64 = '';
if (privatePkcs8) {
publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(privatePkcs8);
} else {
publicKeyB64 = String(current?.blockchainKey || '').trim();
}
const initialBlockchainKey32 = base64ToBytes(publicKeyB64);
if (initialBlockchainKey32.length !== 32 || initialBlockchainKey32.every((value) => value === 0)) {
throw new Error('Не удалось определить initial blockchain key для HEADER');
}
await this.addBlockSigned({
login: cleanLogin,
storagePwd,
param: 'shine',
value: 'yes',
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_HEADER,
msgVersion: 1,
bodyBytes: makeHeaderBodyBytes({ login: cleanLogin, initialBlockchainKey32 }),
_skipHeaderEnsure: true,
});
return this.getUser(login);
return this.getUser(cleanLogin);
}
async ensureChainInitializedForLineOps(login, storagePwd) {
return this.ensureBlockchainHeader(login, storagePwd);
}
async addBlockLike({ login, message, storagePwd }) {
@@ -1914,6 +1835,7 @@ export class AuthService {
return this.runWriteLocked(key, async () => {
const bodyBytes = makeReactionLikeBodyBytes({
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
});
@@ -1937,6 +1859,7 @@ export class AuthService {
return this.runWriteLocked(key, async () => {
const bodyBytes = makeReactionLikeBodyBytes({
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
});
@@ -1961,6 +1884,7 @@ export class AuthService {
return this.runWriteLocked(key, async () => {
const bodyBytes = makeTextReplyBodyBytes({
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
@@ -1986,6 +1910,7 @@ export class AuthService {
return this.runWriteLocked(key, async () => {
const bodyBytes = makeTextRatingBodyBytes({
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
@@ -2026,6 +1951,7 @@ export class AuthService {
return this.runWriteLocked(key, async () => {
const bodyBytes = makeStatusActionBodyBytes({
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
@@ -2099,6 +2025,7 @@ export class AuthService {
prevLineHashHex,
thisLineNumber,
toLogin: target.login,
toForkNumber: target.forkNumber,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
@@ -2198,7 +2125,6 @@ export class AuthService {
}
const bodyBytes = makeTextEditReplyBodyBytes({
toLogin: target.login,
toBlockNumber: target.blockNumber,
toBlockHashHex: target.blockHash,
text: cleanText,
@@ -2273,6 +2199,7 @@ export class AuthService {
prevLineHashHex: ZERO64,
thisLineNumber: -1,
toLogin: cleanTargetLogin,
toForkNumber: forkNumberFromBlockchainNameValue(cleanTargetBch),
toBlockNumber: cleanTargetBlockNumber,
toBlockHashHex: targetHashHex,
});
@@ -3255,14 +3182,20 @@ export class AuthService {
const targetUser = await this.getUser(cleanToLogin);
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
const targetBlockchainName = String(targetUser.blockchainName || '').trim();
const targetForkNumber = forkNumberFromBlockchainNameValue(targetBlockchainName);
if (!targetForkNumber) throw new Error('Не удалось определить fork пользователя цели.');
const targetHeaderHash = await this.resolveHeaderHashForBlockchain(targetBlockchainName);
if (targetHeaderHash === ZERO64) throw new Error('HEADER пользователя цели не найден.');
const bodyBytes = makeConnectionBodyBytes({
lineCode: 0,
prevLineNumber: -1,
prevLineHashHex: ZERO_HASH_HEX,
thisLineNumber: -1,
toLogin: cleanToLogin,
toForkNumber: targetForkNumber,
toBlockNumber: 0,
toBlockHashHex: ZERO_HASH_HEX,
toBlockHashHex: targetHeaderHash,
});
return this.addBlockSigned({
login: cleanLogin,
+16 -3
View File
@@ -1922,8 +1922,9 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
throw new Error(`HEADER: неожиданный tag ${tag}`);
}
const login = reader.readUtf8U8();
const initialBlockchainKeyHex = bytesToHex(reader.readBytes(32));
reader.ensureDone();
return { kind: 'header', login, tag };
return { kind: 'header', login, tag, initialBlockchainKeyHex };
}
function parseCreateChannelBody(bodyBytes) {
@@ -1957,6 +1958,8 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
const prevLineHashHex = bytesToHex(reader.readBytes(32));
const thisLineNumber = reader.readI32();
let targetBlockchainName = '';
let targetLogin = '';
let targetForkNumber = null;
let targetBlockNumber = null;
let targetBlockHashHex = '';
@@ -1964,7 +1967,9 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
targetBlockNumber = reader.readI32();
targetBlockHashHex = bytesToHex(reader.readBytes(32));
} else if (subType === BCH_SUBTYPE_TEXT_REPOST) {
targetBlockchainName = reader.readUtf8U8();
targetLogin = reader.readUtf8U8();
targetForkNumber = reader.readU16();
targetBlockchainName = targetForkNumber > 0 ? `${targetLogin}-${String(targetForkNumber).padStart(3, '0')}` : '';
targetBlockNumber = reader.readI32();
targetBlockHashHex = bytesToHex(reader.readBytes(32));
}
@@ -1979,6 +1984,8 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
prevLineHashHex,
thisLineNumber,
targetBlockchainName,
targetLogin,
targetForkNumber,
targetBlockNumber,
targetBlockHashHex,
message,
@@ -1988,8 +1995,12 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
function parseTextReplyBody(subType, bodyBytes) {
const reader = makeBigEndianReader(bodyBytes, 'TEXT_REPLY');
let targetBlockchainName = '';
let targetLogin = '';
let targetForkNumber = null;
if (subType === BCH_SUBTYPE_TEXT_REPLY) {
targetBlockchainName = reader.readUtf8U8();
targetLogin = reader.readUtf8U8();
targetForkNumber = reader.readU16();
targetBlockchainName = targetForkNumber > 0 ? `${targetLogin}-${String(targetForkNumber).padStart(3, '0')}` : '';
}
const targetBlockNumber = reader.readI32();
const targetBlockHashHex = bytesToHex(reader.readBytes(32));
@@ -1999,6 +2010,8 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
kind: 'text_reply',
subType,
targetBlockchainName,
targetLogin,
targetForkNumber,
targetBlockNumber,
targetBlockHashHex,
message,
+16 -3
View File
@@ -1935,8 +1935,9 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
throw new Error(`HEADER: неожиданный tag ${tag}`);
}
const login = reader.readUtf8U8();
const initialBlockchainKeyHex = bytesToHex(reader.readBytes(32));
reader.ensureDone();
return { kind: 'header', login, tag };
return { kind: 'header', login, tag, initialBlockchainKeyHex };
}
function parseCreateChannelBody(bodyBytes) {
@@ -1970,6 +1971,8 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
const prevLineHashHex = bytesToHex(reader.readBytes(32));
const thisLineNumber = reader.readI32();
let targetBlockchainName = '';
let targetLogin = '';
let targetForkNumber = null;
let targetBlockNumber = null;
let targetBlockHashHex = '';
@@ -1977,7 +1980,9 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
targetBlockNumber = reader.readI32();
targetBlockHashHex = bytesToHex(reader.readBytes(32));
} else if (subType === BCH_SUBTYPE_TEXT_REPOST) {
targetBlockchainName = reader.readUtf8U8();
targetLogin = reader.readUtf8U8();
targetForkNumber = reader.readU16();
targetBlockchainName = targetForkNumber > 0 ? `${targetLogin}-${String(targetForkNumber).padStart(3, '0')}` : '';
targetBlockNumber = reader.readI32();
targetBlockHashHex = bytesToHex(reader.readBytes(32));
}
@@ -1992,6 +1997,8 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
prevLineHashHex,
thisLineNumber,
targetBlockchainName,
targetLogin,
targetForkNumber,
targetBlockNumber,
targetBlockHashHex,
message,
@@ -2001,8 +2008,12 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
function parseTextReplyBody(subType, bodyBytes) {
const reader = makeBigEndianReader(bodyBytes, 'TEXT_REPLY');
let targetBlockchainName = '';
let targetLogin = '';
let targetForkNumber = null;
if (subType === BCH_SUBTYPE_TEXT_REPLY) {
targetBlockchainName = reader.readUtf8U8();
targetLogin = reader.readUtf8U8();
targetForkNumber = reader.readU16();
targetBlockchainName = targetForkNumber > 0 ? `${targetLogin}-${String(targetForkNumber).padStart(3, '0')}` : '';
}
const targetBlockNumber = reader.readI32();
const targetBlockHashHex = bytesToHex(reader.readBytes(32));
@@ -2012,6 +2023,8 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
kind: 'text_reply',
subType,
targetBlockchainName,
targetLogin,
targetForkNumber,
targetBlockNumber,
targetBlockHashHex,
message,
+16 -3
View File
@@ -1915,8 +1915,9 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
throw new Error(`HEADER: неожиданный tag ${tag}`);
}
const login = reader.readUtf8U8();
const initialBlockchainKeyHex = bytesToHex(reader.readBytes(32));
reader.ensureDone();
return { kind: 'header', login, tag };
return { kind: 'header', login, tag, initialBlockchainKeyHex };
}
function parseCreateChannelBody(bodyBytes) {
@@ -1950,6 +1951,8 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
const prevLineHashHex = bytesToHex(reader.readBytes(32));
const thisLineNumber = reader.readI32();
let targetBlockchainName = '';
let targetLogin = '';
let targetForkNumber = null;
let targetBlockNumber = null;
let targetBlockHashHex = '';
@@ -1957,7 +1960,9 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
targetBlockNumber = reader.readI32();
targetBlockHashHex = bytesToHex(reader.readBytes(32));
} else if (subType === BCH_SUBTYPE_TEXT_REPOST) {
targetBlockchainName = reader.readUtf8U8();
targetLogin = reader.readUtf8U8();
targetForkNumber = reader.readU16();
targetBlockchainName = targetForkNumber > 0 ? `${targetLogin}-${String(targetForkNumber).padStart(3, '0')}` : '';
targetBlockNumber = reader.readI32();
targetBlockHashHex = bytesToHex(reader.readBytes(32));
}
@@ -1972,6 +1977,8 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
prevLineHashHex,
thisLineNumber,
targetBlockchainName,
targetLogin,
targetForkNumber,
targetBlockNumber,
targetBlockHashHex,
message,
@@ -1981,8 +1988,12 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
function parseTextReplyBody(subType, bodyBytes) {
const reader = makeBigEndianReader(bodyBytes, 'TEXT_REPLY');
let targetBlockchainName = '';
let targetLogin = '';
let targetForkNumber = null;
if (subType === BCH_SUBTYPE_TEXT_REPLY) {
targetBlockchainName = reader.readUtf8U8();
targetLogin = reader.readUtf8U8();
targetForkNumber = reader.readU16();
targetBlockchainName = targetForkNumber > 0 ? `${targetLogin}-${String(targetForkNumber).padStart(3, '0')}` : '';
}
const targetBlockNumber = reader.readI32();
const targetBlockHashHex = bytesToHex(reader.readBytes(32));
@@ -1992,6 +2003,8 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
kind: 'text_reply',
subType,
targetBlockchainName,
targetLogin,
targetForkNumber,
targetBlockNumber,
targetBlockHashHex,
message,