before merge to main

This commit is contained in:
2026-03-04 16:29:24 +06:00
parent 0481cde1c3
commit 491ccbea89
21 changed files with 6776 additions and 47 deletions

File diff suppressed because it is too large Load Diff

2
data/secret_users.jsonl Normal file
View File

@@ -0,0 +1,2 @@
4jywagSLv0grz1G1ZoRu484Abu5GXaNYA9LsMmRQIxmTMuFczm4jRGYZm4JwQ+8YuWhjsOs0CRyOtB+dg0skg176sz9ES85DPGGiemJB9bZBKORMB+O4UL9dH5j9
poBf6u/pAsjCFu0twwoSHspgUXXNGliXHoJ8eqtL82demTjpTo9+SXzbUSaAUsUgdy1XQJZlncOIWeTlxgfOJXuqpXPpqNJtxyZd1E/9+jOgRreQXOmyMg==

View File

@@ -0,0 +1,2 @@
4jywagSLv0grz1G1ZoRu484Abu5GXaNYA9LsMmRQIxmTMuFczm4jRGYZm4JwQ+8YuWhjsOs0CRyOtB+dg0skg176sz9ES85DPGGiemJB9bZBKORMB+O4UL9dH5j9
poBf6u/pAsjCFu0twwoSHspgUXXNGliXHoJ8eqtL82demTjpTo9+SXzbUSaAUsUgdy1XQJZlncOIWeTlxgfOJXuqpXPpqNJtxyZd1E/9+jOgRreQXOmyMg==

View File

@@ -0,0 +1,4 @@
{"id":1,"x":"short"}
{"id":1,"x":"short"}
{"id":1,"x":"short"}
{"id":1,"x":"short"}

View File

@@ -1 +0,0 @@
{"createdAt":1755088501,"email":"test@example.com","id":1,"isActive":true,"role":"user","username":"testuser"}

View File

@@ -1,2 +1,2 @@
{"age":31,"created":"2026-03-04T09:07:22+06:00","email":"john@example.com","id":1,"name":"John Doe"} {"age":31,"created":"2026-03-04T13:58:25+06:00","email":"john@example.com","id":1,"name":"John Doe"}
{"age":35,"created":"2026-03-04T09:07:22+06:00","email":"bob@example.com","id":3,"name":"Bob Johnson"} {"age":35,"created":"2026-03-04T13:58:25+06:00","email":"bob@example.com","id":3,"name":"Bob Johnson"}

View File

@@ -0,0 +1,74 @@
// Пример использования KeyOrder в опциях коллекции — встроенная сортировка ключей при сериализации.
package main
import (
"fmt"
"log"
"os"
"time"
"linedb/pkg/linedb"
)
// Item — структура для вставки (LineDB поддерживает struct и map)
type Item struct {
Name string `json:"name"`
Value int `json:"value"`
Active bool `json:"active"`
Atime string `json:"atime"`
}
func main() {
os.RemoveAll("./data")
// KeyOrder в опциях коллекции — при задании используется кастомная сериализация с порядком ключей
initOptions := &linedb.LineDbInitOptions{
CacheSize: 100,
CacheTTL: time.Minute,
DBFolder: "./data",
Collections: []linedb.JSONLFileOptions{
{
CollectionName: "items",
AllocSize: 256,
KeyOrder: []linedb.KeyOrder{
{Key: "id", Order: 0},
{Key: "atime", Order: -1},
},
},
},
}
db := linedb.NewLineDb(nil)
if err := db.Init(false, initOptions); err != nil {
log.Fatalf("Init failed: %v", err)
}
defer db.Close()
opts := linedb.LineDbAdapterOptions{}
// Инсерты: map и структуры (LineDB принимает оба)
items := []any{
map[string]any{"Value": 42, "Name": "Test", "Active": true, "atime": "2024-01-01"},
map[string]any{"Active": false, "Name": "Alice", "Value": 100, "atime": "2024-01-02"},
map[string]any{"Name": "Bob", "Value": 0, "Active": true, "atime": "2024-01-03"},
Item{Name: "Charlie", Value: 7, Active: true, Atime: "2024-01-04"},
Item{Name: "Diana", Value: 99, Active: false, Atime: "2024-01-05"},
}
if err := db.Insert(items, "items", opts); err != nil {
log.Fatalf("Insert failed: %v", err)
}
// Read
all, err := db.Read("items", opts)
if err != nil {
log.Fatalf("Read failed: %v", err)
}
fmt.Printf("Records: %d\n", len(all))
for _, r := range all {
fmt.Printf(" %+v\n", r)
}
raw, _ := os.ReadFile("./data/items.jsonl")
fmt.Printf("\nRaw file:\n%s\n", string(raw))
}

61
examples/encode/main.go Normal file
View File

@@ -0,0 +1,61 @@
// Пример использования Encode — шифрование записей в коллекции (AES-256-GCM).
package main
import (
"fmt"
"log"
"os"
"time"
"linedb/pkg/linedb"
)
func main() {
os.RemoveAll("./data")
initOptions := &linedb.LineDbInitOptions{
CacheSize: 100,
CacheTTL: time.Minute,
DBFolder: "./data",
Collections: []linedb.JSONLFileOptions{
{
CollectionName: "secret_users",
AllocSize: 256,
Encode: true,
EncodeKey: "my-secret-password-12345",
},
},
}
db := linedb.NewLineDb(nil)
if err := db.Init(false, initOptions); err != nil {
log.Fatalf("Init failed: %v", err)
}
defer db.Close()
opts := linedb.LineDbAdapterOptions{}
// Вставка
users := []any{
map[string]any{"name": "alice", "email": "alice@secret.com", "role": "admin"},
map[string]any{"name": "bob", "email": "bob@secret.com", "role": "user"},
}
if err := db.Insert(users, "secret_users", opts); err != nil {
log.Fatalf("Insert failed: %v", err)
}
fmt.Println("Insert OK")
// Чтение
all, err := db.Read("secret_users", opts)
if err != nil {
log.Fatalf("Read failed: %v", err)
}
fmt.Printf("Records: %d\n", len(all))
for _, r := range all {
fmt.Printf(" %+v\n", r)
}
// Файл содержит зашифрованные строки (base64)
raw, _ := os.ReadFile("./data/secret_users.jsonl")
fmt.Printf("\nRaw file (encrypted base64):\n%s\n", string(raw))
}

206
examples/perf/main.go Normal file
View File

@@ -0,0 +1,206 @@
// Пример комплексного performance-теста LineDb.
// Запуск:
//
// go run ./examples/perf/main.go
//
// Сценарий:
// 1. Вставка 5000 записей, каждая ~1800 символов.
// 2. 10 случайных обновлений, замер среднего времени.
// 3. 100 случайных удалений, замер среднего времени.
// 4. Замеры памяти процесса через runtime.MemStats.
package main
import (
"fmt"
"log"
"math/rand"
"os"
"runtime"
"strings"
"time"
"linedb/pkg/linedb"
)
const (
recordsCount = 5000
payloadSize = 1800
updateOps = 100
deleteOps = 100
collectionName = "perf_items"
dbDir = "./data/perf-benchmark"
allocSizeEstimate = 2048
)
func main() {
rand.Seed(time.Now().UnixNano())
if err := os.MkdirAll(dbDir, 0755); err != nil {
log.Fatalf("mkdir: %v", err)
}
initOptions := &linedb.LineDbInitOptions{
CacheSize: 1000,
CacheTTL: time.Minute,
DBFolder: dbDir,
Collections: []linedb.JSONLFileOptions{
{
CollectionName: collectionName,
AllocSize: allocSizeEstimate,
},
},
}
db := linedb.NewLineDb(nil)
if err := db.Init(true, initOptions); err != nil {
log.Fatalf("Init failed: %v", err)
}
defer db.Close()
printMem("Before insert")
fmt.Printf("1) Insert %d records (payload ~%d chars)...\n", recordsCount, payloadSize)
start := time.Now()
if err := insertRecords(db); err != nil {
log.Fatalf("InsertRecords failed: %v", err)
}
elapsedInsert := time.Since(start)
fmt.Printf(" Total insert time: %v, per record: %v\n",
elapsedInsert, elapsedInsert/time.Duration(recordsCount))
printMem("After insert")
all, err := db.Read(collectionName, linedb.LineDbAdapterOptions{})
if err != nil {
log.Fatalf("Read after insert failed: %v", err)
}
fmt.Printf(" Records in collection: %d\n", len(all))
ids := collectIDs(all)
if len(ids) == 0 {
log.Fatalf("No IDs collected, cannot continue")
}
fmt.Printf("\n2) Random update of %d records...\n", updateOps)
avgUpdate := benchmarkUpdates(db, ids, updateOps)
fmt.Printf(" Average update time: %v\n", avgUpdate)
printMem("After updates")
fmt.Printf("\n3) Random delete of %d records...\n", deleteOps)
avgDelete := benchmarkDeletes(db, ids, deleteOps)
fmt.Printf(" Average delete time: %v\n", avgDelete)
printMem("After deletes")
final, err := db.Read(collectionName, linedb.LineDbAdapterOptions{})
if err != nil {
log.Fatalf("Final read failed: %v", err)
}
fmt.Printf("\nFinal records in collection: %d\n", len(final))
fmt.Printf("Data directory: %s\n", dbDir)
}
func insertRecords(db *linedb.LineDb) error {
// Сгенерируем базовый payload
base := strings.Repeat("X", payloadSize)
batch := make([]any, 0, 100)
for i := 0; i < recordsCount; i++ {
rec := map[string]any{
"index": i,
"payload": base,
"created": time.Now().Format(time.RFC3339Nano),
}
batch = append(batch, rec)
if len(batch) >= cap(batch) {
if err := db.Insert(batch, collectionName, linedb.LineDbAdapterOptions{}); err != nil {
return err
}
batch = batch[:0]
}
}
if len(batch) > 0 {
if err := db.Insert(batch, collectionName, linedb.LineDbAdapterOptions{}); err != nil {
return err
}
}
return nil
}
func collectIDs(all []any) []any {
ids := make([]any, 0, len(all))
for _, r := range all {
if m, ok := r.(map[string]any); ok {
if id, ok := m["id"]; ok {
ids = append(ids, id)
}
}
}
return ids
}
func benchmarkUpdates(db *linedb.LineDb, ids []any, ops int) time.Duration {
if len(ids) == 0 {
return 0
}
var total time.Duration
for i := 0; i < ops; i++ {
id := ids[rand.Intn(len(ids))]
update := map[string]any{
"updatedAt": time.Now().Format(time.RFC3339Nano),
}
start := time.Now()
_, err := db.Update(update, collectionName, map[string]any{"id": id}, linedb.LineDbAdapterOptions{})
dur := time.Since(start)
if err != nil {
log.Printf("Update error (id=%v): %v", id, err)
continue
}
total += dur
}
if ops == 0 {
return 0
}
return total / time.Duration(ops)
}
func benchmarkDeletes(db *linedb.LineDb, ids []any, ops int) time.Duration {
if len(ids) == 0 {
return 0
}
var total time.Duration
used := make(map[int]struct{})
for i := 0; i < ops; i++ {
// чтобы не удалять по одному и тому же id постоянно, постараемся брать разные индексы
var idx int
for tries := 0; tries < 10; tries++ {
idx = rand.Intn(len(ids))
if _, ok := used[idx]; !ok {
break
}
}
used[idx] = struct{}{}
id := ids[idx]
start := time.Now()
_, err := db.Delete(map[string]any{"id": id}, collectionName, linedb.LineDbAdapterOptions{})
dur := time.Since(start)
if err != nil {
log.Printf("Delete error (id=%v): %v", id, err)
continue
}
total += dur
}
if ops == 0 {
return 0
}
return total / time.Duration(ops)
}
func printMem(label string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("\n=== %s ===\n", label)
fmt.Printf(" Alloc: %.2f MB\n", float64(m.Alloc)/1024.0/1024.0)
fmt.Printf(" TotalAlloc: %.2f MB\n", float64(m.TotalAlloc)/1024.0/1024.0)
fmt.Printf(" Sys: %.2f MB\n", float64(m.Sys)/1024.0/1024.0)
fmt.Printf(" NumGC: %d\n", m.NumGC)
}

View File

@@ -0,0 +1,95 @@
// Тест: ошибка при записи, превышающей allocSize-1.
// Запуск: go run ./examples/test-alloc-overflow/main.go
// Файлы остаются в ./data/test-alloc-overflow/
package main
import (
"fmt"
"log"
"os"
"strings"
"time"
"linedb/pkg/linedb"
)
func main() {
dataDir := "./data/test-alloc-overflow"
os.MkdirAll(dataDir, 0755)
initOptions := &linedb.LineDbInitOptions{
CacheSize: 100,
CacheTTL: time.Minute,
DBFolder: dataDir,
Collections: []linedb.JSONLFileOptions{
{CollectionName: "tiny", AllocSize: 64},
},
}
db := linedb.NewLineDb(nil)
if err := db.Init(false, initOptions); err != nil {
log.Fatalf("Init failed: %v", err)
}
defer db.Close()
opts := linedb.LineDbAdapterOptions{}
// Короткая запись — должна пройти
fmt.Println("1. Insert короткой записи...")
if err := db.Insert(map[string]any{"x": "short"}, "tiny", opts); err != nil {
log.Fatalf("Insert short failed: %v", err)
}
fmt.Println(" OK")
// Длинная — ожидаем ошибку
fmt.Println("2. Insert длинной записи (ожидаем ошибку)...")
longStr := strings.Repeat("a", 80)
err := db.Insert(map[string]any{"x": longStr}, "tiny", opts)
if err == nil {
log.Fatal("Ожидалась ошибка, но Insert прошёл")
}
fmt.Printf(" Ошибка (ожидаемо): %v\n", err)
if !strings.Contains(err.Error(), "exceeds") {
log.Fatalf("Ожидалось 'exceeds' в ошибке, получено: %v", err)
}
fmt.Println(" OK — ошибка корректная")
// Итог
all, _ := db.Read("tiny", opts)
fmt.Printf("\nЗаписей в коллекции после insert-теста: %d\n", len(all))
//короткий update
fmt.Println("\n3. Update с коротким значением...")
_, err = db.Update(
map[string]any{"x": "short"},
"tiny",
map[string]any{"x": "short"},
opts,
)
if err != nil {
log.Fatalf("Update short failed: %v", err)
}
fmt.Println(" OK")
// Длинный update — ожидаем ошибку при переписывании файла
fmt.Println("\n4. Update с длинным значением (ожидаем ошибку)...")
longUpdate := strings.Repeat("b", 80)
_, err = db.Update(
map[string]any{"x": longUpdate},
"tiny",
map[string]any{"x": "short"},
opts,
)
if err == nil {
log.Fatal("Ожидалась ошибка, но Update прошёл")
}
fmt.Printf(" Ошибка (ожидаемо): %v\n", err)
if !strings.Contains(err.Error(), "exceeds") {
log.Fatalf("Ожидалось 'exceeds' в ошибке, получено: %v", err)
}
fmt.Println(" OK — ошибка при update корректная")
all, _ = db.Read("tiny", opts)
fmt.Printf("\nЗаписей в коллекции после update-теста: %d\n", len(all))
fmt.Printf("Файл сохранён: %s/tiny.jsonl\n", dataDir)
}

View File

@@ -0,0 +1,57 @@
// Тест: нормализация существующего файла при Init (приведение записей к allocSize-1).
// Запуск: go run ./examples/test-init-normalize/main.go
// Файлы остаются в ./data/test-init-normalize/
package main
import (
"fmt"
"log"
"os"
"time"
"linedb/pkg/linedb"
)
func main() {
dataDir := "./data/test-init-normalize"
os.MkdirAll(dataDir, 0755)
// Создаём файл с записями разной длины ДО Init
filePath := dataDir + "/norm.jsonl"
content := `{"id":1,"a":"x"}
{"id":2,"a":"yy"}
{"id":3,"a":"zzz"} `
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
log.Fatalf("Write file: %v", err)
}
fmt.Printf("Создан файл с записями разной длины:\n%s\n", content)
initOptions := &linedb.LineDbInitOptions{
CacheSize: 100,
CacheTTL: time.Minute,
DBFolder: dataDir,
Collections: []linedb.JSONLFileOptions{
{CollectionName: "norm", AllocSize: 128},
},
}
db := linedb.NewLineDb(nil)
fmt.Println("\nInit (нормализация записей к allocSize-1=99)...")
if err := db.Init(false, initOptions); err != nil {
log.Fatalf("Init failed: %v", err)
}
defer db.Close()
all, err := db.Read("norm", linedb.LineDbAdapterOptions{})
if err != nil {
log.Fatalf("Read failed: %v", err)
}
fmt.Printf("Прочитано записей: %d\n", len(all))
for i, r := range all {
fmt.Printf(" [%d] %+v\n", i+1, r)
}
raw, _ := os.ReadFile(filePath)
fmt.Printf("\nФайл после нормализации:\n%s\n", string(raw))
fmt.Printf("Файл сохранён: %s\n", filePath)
}

1
pkg/linedb/Untitled Normal file
View File

@@ -0,0 +1 @@
д

View File

@@ -2,11 +2,17 @@ package linedb
import ( import (
"bufio" "bufio"
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -23,6 +29,151 @@ func defaultJSONUnmarshal(data []byte, v any) error {
return goccyjson.Unmarshal(data, v) return goccyjson.Unmarshal(data, v)
} }
// makeKeyOrderMarshal создаёт JSONMarshal с заданным порядком ключей
func makeKeyOrderMarshal(keyOrder []KeyOrder) func(any) ([]byte, error) {
return func(v any) ([]byte, error) {
if m, ok := v.(map[string]any); ok {
return marshalMapSorted(m, keyOrder)
}
return goccyjson.Marshal(v)
}
}
func marshalMapSorted(m map[string]any, keyOrder []KeyOrder) ([]byte, error) {
orderMap := make(map[string]int)
for _, ko := range keyOrder {
orderMap[ko.Key] = ko.Order
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
oi, hasI := orderMap[keys[i]]
oj, hasJ := orderMap[keys[j]]
group := func(o int, has bool) int {
if !has {
return 1
}
if o >= 0 {
return 0
}
return 2
}
gi, gj := group(oi, hasI), group(oj, hasJ)
if gi != gj {
return gi < gj
}
switch gi {
case 0:
return oi < oj
case 1:
return keys[i] < keys[j]
default:
return oi < oj
}
})
var buf bytes.Buffer
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
keyEscaped, _ := goccyjson.Marshal(k)
buf.Write(keyEscaped)
buf.WriteByte(':')
val := m[k]
if nested, ok := val.(map[string]any); ok {
valBytes, err := marshalMapSorted(nested, keyOrder)
if err != nil {
return nil, err
}
buf.Write(valBytes)
} else {
valBytes, err := goccyjson.Marshal(val)
if err != nil {
return nil, err
}
buf.Write(valBytes)
}
}
buf.WriteByte('}')
return buf.Bytes(), nil
}
// encodeKeyBytes превращает строку ключа в 32 байта (SHA256) для AES-256
func encodeKeyBytes(keyStr string) []byte {
h := sha256.Sum256([]byte(keyStr))
return h[:]
}
// aesGCMEncrypt — встроенное AES-256-GCM шифрование
func aesGCMEncrypt(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, aesgcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return aesgcm.Seal(nonce, nonce, plaintext, nil), nil
}
// aesGCMDecrypt — встроенная расшифровка AES-256-GCM
func aesGCMDecrypt(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := aesgcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
return aesgcm.Open(nil, nonce, ciphertext, nil)
}
// makeEncodedMarshal оборачивает marshal в шифрование: marshal -> encrypt -> base64
func makeEncodedMarshal(marshal func(any) ([]byte, error), encFn func([]byte, []byte) ([]byte, error), key []byte) func(any) ([]byte, error) {
return func(v any) ([]byte, error) {
jsonData, err := marshal(v)
if err != nil {
return nil, err
}
encrypted, err := encFn(jsonData, key)
if err != nil {
return nil, err
}
return []byte(base64.StdEncoding.EncodeToString(encrypted)), nil
}
}
// makeEncodedUnmarshal оборачивает unmarshal в дешифрование: base64 -> decrypt -> unmarshal
func makeEncodedUnmarshal(unmarshal func([]byte, any) error, decFn func([]byte, []byte) ([]byte, error), key []byte) func([]byte, any) error {
return func(data []byte, v any) error {
encrypted, err := base64.StdEncoding.DecodeString(string(data))
if err != nil {
return err
}
plaintext, err := decFn(encrypted, key)
if err != nil {
return err
}
return unmarshal(plaintext, v)
}
}
// JSONLFile представляет адаптер для работы с JSONL файлами // JSONLFile представляет адаптер для работы с JSONL файлами
type JSONLFile struct { type JSONLFile struct {
filename string filename string
@@ -61,14 +212,31 @@ func NewJSONLFile(filename string, cypherKey string, options JSONLFileOptions) *
jsonMarshal := defaultJSONMarshal jsonMarshal := defaultJSONMarshal
jsonUnmarshal := defaultJSONUnmarshal jsonUnmarshal := defaultJSONUnmarshal
// Используем пользовательские функции если они предоставлены
if options.JSONMarshal != nil { if options.JSONMarshal != nil {
jsonMarshal = options.JSONMarshal jsonMarshal = options.JSONMarshal
} else if len(options.KeyOrder) > 0 {
jsonMarshal = makeKeyOrderMarshal(options.KeyOrder)
} }
if options.JSONUnmarshal != nil { if options.JSONUnmarshal != nil {
jsonUnmarshal = options.JSONUnmarshal jsonUnmarshal = options.JSONUnmarshal
} }
// Encode: оборачиваем marshal/unmarshal в шифрование
if options.Encode && options.EncodeKey != "" {
key := encodeKeyBytes(options.EncodeKey)
encFn := options.EncryptFn
decFn := options.DecryptFn
if encFn == nil {
encFn = aesGCMEncrypt
}
if decFn == nil {
decFn = aesGCMDecrypt
}
jsonMarshal = makeEncodedMarshal(jsonMarshal, encFn, key)
jsonUnmarshal = makeEncodedUnmarshal(jsonUnmarshal, decFn, key)
}
return &JSONLFile{ return &JSONLFile{
filename: filename, filename: filename,
cypherKey: cypherKey, cypherKey: cypherKey,
@@ -99,18 +267,87 @@ func (j *JSONLFile) Init(force bool, options LineDbAdapterOptions) error {
} }
// Создаем файл если не существует // Создаем файл если не существует
if _, err := os.Stat(j.filename); os.IsNotExist(err) { if st, err := os.Stat(j.filename); os.IsNotExist(err) {
file, err := os.Create(j.filename) file, err := os.Create(j.filename)
if err != nil { if err != nil {
return fmt.Errorf("failed to create file: %w", err) return fmt.Errorf("failed to create file: %w", err)
} }
file.Close() file.Close()
} else if err == nil && st.Size() > 0 {
// Файл существует и не пустой — проверяем и нормализуем записи
if err := j.normalizeExistingFile(); err != nil {
return err
}
} }
j.initialized = true j.initialized = true
return nil return nil
} }
// normalizeExistingFile проверяет размеры записей и приводит к allocSize-1
func (j *JSONLFile) normalizeExistingFile() error {
data, err := os.ReadFile(j.filename)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
var nonEmpty []string
for _, l := range lines {
if l == "" {
continue
}
nonEmpty = append(nonEmpty, l)
}
if len(nonEmpty) == 0 {
return nil
}
// Максимальная длина среди записей
maxLen := 0
for _, l := range nonEmpty {
if len(l) > maxLen {
maxLen = len(l)
}
}
// Приводим короткие к maxLen (добавляем пробелы)
for i, l := range nonEmpty {
if len(l) < maxLen {
nonEmpty[i] = l + strings.Repeat(" ", maxLen-len(l))
}
}
targetLen := j.allocSize - 1
if targetLen < 1 {
targetLen = 1
}
if targetLen < maxLen {
// Уменьшаем: можно только если данные (без trailing spaces) помещаются
for i := range nonEmpty {
trimmed := strings.TrimRight(nonEmpty[i], " ")
if len(trimmed) > targetLen {
return fmt.Errorf("init failed: record data size %d exceeds configured allocSize-1 (%d), cannot reduce without data loss",
len(trimmed), targetLen)
}
nonEmpty[i] = trimmed + strings.Repeat(" ", targetLen-len(trimmed))
}
} else if targetLen > maxLen {
// Расширяем
for i := range nonEmpty {
nonEmpty[i] = nonEmpty[i] + strings.Repeat(" ", targetLen-len(nonEmpty[i]))
}
}
// Перезаписываем файл
var buf bytes.Buffer
for _, l := range nonEmpty {
buf.WriteString(l)
buf.WriteByte('\n')
}
return os.WriteFile(j.filename, buf.Bytes(), 0644)
}
// Read читает все записи из файла // Read читает все записи из файла
func (j *JSONLFile) Read(options LineDbAdapterOptions) ([]any, error) { func (j *JSONLFile) Read(options LineDbAdapterOptions) ([]any, error) {
j.mutex.RLock() j.mutex.RLock()
@@ -135,8 +372,8 @@ func (j *JSONLFile) Read(options LineDbAdapterOptions) ([]any, error) {
continue continue
} }
// Расшифровываем если нужно // Расшифровываем если нужно (только cypherKey; Encode обрабатывается в jsonUnmarshal)
if j.cypherKey != "" { if j.cypherKey != "" && !j.options.Encode {
decoded, err := base64.StdEncoding.DecodeString(line) decoded, err := base64.StdEncoding.DecodeString(line)
if err != nil { if err != nil {
if j.options.SkipInvalidLines { if j.options.SkipInvalidLines {
@@ -189,14 +426,18 @@ func (j *JSONLFile) Write(data any, options LineDbAdapterOptions) error {
line := string(jsonData) line := string(jsonData)
// Шифруем если нужно // Шифруем если нужно (только cypherKey; Encode уже в jsonMarshal)
if j.cypherKey != "" { if j.cypherKey != "" && !j.options.Encode {
line = base64.StdEncoding.EncodeToString([]byte(line)) line = base64.StdEncoding.EncodeToString([]byte(line))
} }
// Дополняем до allocSize maxLineLen := j.allocSize - 1
if len(line) < j.allocSize { if len(line) > maxLineLen {
line += strings.Repeat(" ", j.allocSize-len(line)-1) return fmt.Errorf("record size %d exceeds allocSize-1 (%d)", len(line), maxLineLen)
}
// Дополняем до allocSize-1
if len(line) < maxLineLen {
line += strings.Repeat(" ", maxLineLen-len(line))
} }
if _, err := file.WriteString(line + "\n"); err != nil { if _, err := file.WriteString(line + "\n"); err != nil {
@@ -406,14 +647,18 @@ func (j *JSONLFile) rewriteFile(records []any) error {
line := string(jsonData) line := string(jsonData)
// Шифруем если нужно // Шифруем если нужно (только cypherKey; Encode уже в jsonMarshal)
if j.cypherKey != "" { if j.cypherKey != "" && !j.options.Encode {
line = base64.StdEncoding.EncodeToString([]byte(line)) line = base64.StdEncoding.EncodeToString([]byte(line))
} }
// Дополняем до allocSize maxLineLen := j.allocSize - 1
if len(line) < j.allocSize { if len(line) > maxLineLen {
line += strings.Repeat(" ", j.allocSize-len(line)-1) return fmt.Errorf("record size %d exceeds allocSize-1 (%d)", len(line), maxLineLen)
}
// Дополняем до allocSize-1
if len(line) < maxLineLen {
line += strings.Repeat(" ", maxLineLen-len(line))
} }
if _, err := file.WriteString(line + "\n"); err != nil { if _, err := file.WriteString(line + "\n"); err != nil {

View File

@@ -8,6 +8,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
goccyjson "github.com/goccy/go-json"
) )
// LineDb представляет основную базу данных // LineDb представляет основную базу данных
@@ -159,9 +161,9 @@ func (db *LineDb) Insert(data any, collectionName string, options LineDbAdapterO
resultDataArray := make([]any, 0, len(dataArray)) resultDataArray := make([]any, 0, len(dataArray))
for _, item := range dataArray { for _, item := range dataArray {
itemMap, ok := item.(map[string]any) itemMap, err := db.toMap(item)
if !ok { if err != nil {
return fmt.Errorf("invalid data format") return fmt.Errorf("invalid data format: %w", err)
} }
// Генерируем ID если отсутствует // Генерируем ID если отсутствует
@@ -220,7 +222,12 @@ func (db *LineDb) Insert(data any, collectionName string, options LineDbAdapterO
} }
} }
// Проверяем уникальность полей из UniqueFields // Проверяем обязательные поля (required)
if err := db.checkRequiredFieldsInsert(itemMap, collectionName); err != nil {
return err
}
// Проверяем уникальность полей
if err := db.checkUniqueFieldsInsert(itemMap, collectionName, resultDataArray, options); err != nil { if err := db.checkUniqueFieldsInsert(itemMap, collectionName, resultDataArray, options); err != nil {
return err return err
} }
@@ -292,24 +299,32 @@ func (db *LineDb) Update(data any, collectionName string, filter any, options Li
collectionName = db.getFirstCollection() collectionName = db.getFirstCollection()
} }
// Конвертируем data в map (struct или map)
dataMap, err := db.toMap(data)
if err != nil {
return nil, fmt.Errorf("invalid update data format: %w", err)
}
// Проверяем конфликт ID // Проверяем конфликт ID
if dataMap, ok := data.(map[string]any); ok { if filterMap, ok := filter.(map[string]any); ok {
if filterMap, ok := filter.(map[string]any); ok { if dataMap["id"] != nil && filterMap["id"] != nil {
if dataMap["id"] != nil && filterMap["id"] != nil { if !db.compareIDs(dataMap["id"], filterMap["id"]) {
if !db.compareIDs(dataMap["id"], filterMap["id"]) { return nil, fmt.Errorf("you can not update record id with filter by another id. Use delete and insert instead")
return nil, fmt.Errorf("you can not update record id with filter by another id. Use delete and insert instead")
}
} }
} }
// Проверяем уникальность полей из UniqueFields }
if err := db.checkUniqueFieldsUpdate(dataMap, filter, collectionName, options); err != nil { // Проверяем обязательные поля (required)
return nil, err if err := db.checkRequiredFieldsUpdate(dataMap, collectionName); err != nil {
} return nil, err
}
// Проверяем уникальность полей
if err := db.checkUniqueFieldsUpdate(dataMap, filter, collectionName, options); err != nil {
return nil, err
} }
// Проверяем партиционирование // Проверяем партиционирование
if db.isCollectionPartitioned(collectionName) { if db.isCollectionPartitioned(collectionName) {
return db.updatePartitioned(data, collectionName, filter, options) return db.updatePartitioned(dataMap, collectionName, filter, options)
} }
// Обычное обновление // Обычное обновление
@@ -318,7 +333,7 @@ func (db *LineDb) Update(data any, collectionName string, filter any, options Li
return nil, fmt.Errorf("collection %s not found", collectionName) return nil, fmt.Errorf("collection %s not found", collectionName)
} }
return adapter.Update(data, filter, options) return adapter.Update(dataMap, filter, options)
} }
// Delete удаляет записи из коллекции // Delete удаляет записи из коллекции
@@ -499,7 +514,7 @@ func (db *LineDb) getCollectionOptions(collectionName string) *JSONLFileOptions
return nil return nil
} }
// isValueEmpty проверяет, считается ли значение "пустым" (пропускаем проверку уникальности для пустых) // isValueEmpty проверяет, считается ли значение "пустым" (nil или "" для string) — для обратной совместимости
func (db *LineDb) isValueEmpty(v any) bool { func (db *LineDb) isValueEmpty(v any) bool {
if v == nil { if v == nil {
return true return true
@@ -510,20 +525,119 @@ func (db *LineDb) isValueEmpty(v any) bool {
return false return false
} }
// isEmptyByMode проверяет, пусто ли значение по заданному режиму
func (db *LineDb) isEmptyByMode(m map[string]any, key string, mode EmptyValueMode) bool {
if mode == 0 {
mode = DefaultEmptyModeForUnique
}
v, exists := m[key]
if mode&EmptyModeAbsentKey != 0 && !exists {
return true
}
if mode&EmptyModeNil != 0 && v == nil {
return true
}
if mode&EmptyModeZeroValue != 0 {
switch val := v.(type) {
case string:
return val == ""
case int:
return val == 0
case int64:
return val == 0
case float64:
return val == 0
}
}
return false
}
// getUniqueFieldConstraints возвращает поля с unique и их EmptyMode (из FieldConstraints или UniqueFields)
func (db *LineDb) getUniqueFieldConstraints(collectionName string) []FieldConstraint {
opts := db.getCollectionOptions(collectionName)
if opts == nil {
return nil
}
if len(opts.FieldConstraints) > 0 {
var out []FieldConstraint
for _, fc := range opts.FieldConstraints {
if fc.Unique {
mode := fc.EmptyMode
if mode == 0 {
mode = DefaultEmptyModeForUnique
}
out = append(out, FieldConstraint{Name: fc.Name, Unique: true, EmptyMode: mode})
}
}
return out
}
// Legacy UniqueFields
var out []FieldConstraint
for _, name := range opts.UniqueFields {
out = append(out, FieldConstraint{Name: name, Unique: true, EmptyMode: DefaultEmptyModeForUnique})
}
return out
}
// getRequiredFieldConstraints возвращает required поля и их EmptyMode
func (db *LineDb) getRequiredFieldConstraints(collectionName string) []FieldConstraint {
opts := db.getCollectionOptions(collectionName)
if opts == nil {
return nil
}
var out []FieldConstraint
for _, fc := range opts.FieldConstraints {
if fc.Required {
mode := fc.EmptyMode
if mode == 0 {
mode = DefaultEmptyModeForRequired
}
out = append(out, FieldConstraint{Name: fc.Name, Required: true, EmptyMode: mode})
}
}
return out
}
// checkRequiredFieldsInsert проверяет обязательные поля при вставке
func (db *LineDb) checkRequiredFieldsInsert(itemMap map[string]any, collectionName string) error {
required := db.getRequiredFieldConstraints(collectionName)
for _, fc := range required {
if db.isEmptyByMode(itemMap, fc.Name, fc.EmptyMode) {
return fmt.Errorf("required field %q is empty in collection %q", fc.Name, collectionName)
}
}
return nil
}
// checkRequiredFieldsUpdate проверяет, что при Update не устанавливаются пустые значения в required полях
func (db *LineDb) checkRequiredFieldsUpdate(data map[string]any, collectionName string) error {
required := db.getRequiredFieldConstraints(collectionName)
for _, fc := range required {
if _, inData := data[fc.Name]; !inData {
continue // поле не обновляется — не проверяем
}
if db.isEmptyByMode(data, fc.Name, fc.EmptyMode) {
return fmt.Errorf("required field %q cannot be set to empty in collection %q", fc.Name, collectionName)
}
}
return nil
}
// checkUniqueFieldsInsert проверяет уникальность полей при вставке // checkUniqueFieldsInsert проверяет уникальность полей при вставке
func (db *LineDb) checkUniqueFieldsInsert(itemMap map[string]any, collectionName string, resultDataArray []any, options LineDbAdapterOptions) error { func (db *LineDb) checkUniqueFieldsInsert(itemMap map[string]any, collectionName string, resultDataArray []any, options LineDbAdapterOptions) error {
if options.SkipCheckExistingForWrite { if options.SkipCheckExistingForWrite {
return nil return nil
} }
opts := db.getCollectionOptions(collectionName) uniqueFields := db.getUniqueFieldConstraints(collectionName)
if opts == nil || len(opts.UniqueFields) == 0 { if len(uniqueFields) == 0 {
return nil return nil
} }
for _, fieldName := range opts.UniqueFields { for _, fc := range uniqueFields {
value := itemMap[fieldName] fieldName := fc.Name
if db.isValueEmpty(value) { if db.isEmptyByMode(itemMap, fieldName, fc.EmptyMode) {
continue continue
} }
value := itemMap[fieldName]
// Проверяем в batch (уже добавляемые записи) // Проверяем в batch (уже добавляемые записи)
for _, resultItem := range resultDataArray { for _, resultItem := range resultDataArray {
if resultMap, ok := resultItem.(map[string]any); ok { if resultMap, ok := resultItem.(map[string]any); ok {
@@ -552,8 +666,8 @@ func (db *LineDb) checkUniqueFieldsUpdate(data map[string]any, filter any, colle
if options.SkipCheckExistingForWrite { if options.SkipCheckExistingForWrite {
return nil return nil
} }
opts := db.getCollectionOptions(collectionName) uniqueFields := db.getUniqueFieldConstraints(collectionName)
if opts == nil || len(opts.UniqueFields) == 0 { if len(uniqueFields) == 0 {
return nil return nil
} }
recordsToUpdate, err := db.ReadByFilter(filter, collectionName, LineDbAdapterOptions{InTransaction: true}) recordsToUpdate, err := db.ReadByFilter(filter, collectionName, LineDbAdapterOptions{InTransaction: true})
@@ -566,9 +680,10 @@ func (db *LineDb) checkUniqueFieldsUpdate(data map[string]any, filter any, colle
updatingIDs[m["id"]] = true updatingIDs[m["id"]] = true
} }
} }
for _, fieldName := range opts.UniqueFields { for _, fc := range uniqueFields {
fieldName := fc.Name
value, inData := data[fieldName] value, inData := data[fieldName]
if !inData || db.isValueEmpty(value) { if !inData || db.isEmptyByMode(data, fieldName, fc.EmptyMode) {
continue continue
} }
existing, err := db.ReadByFilter(map[string]any{fieldName: value}, collectionName, LineDbAdapterOptions{InTransaction: true}) existing, err := db.ReadByFilter(map[string]any{fieldName: value}, collectionName, LineDbAdapterOptions{InTransaction: true})
@@ -707,6 +822,19 @@ func (db *LineDb) toNumber(value any) (float64, bool) {
return 0, false return 0, false
} }
// toMap конвертирует struct или map в map[string]any (для Insert/Update)
func (db *LineDb) toMap(v any) (map[string]any, error) {
if m, ok := v.(map[string]any); ok {
return m, nil
}
data, err := goccyjson.Marshal(v)
if err != nil {
return nil, err
}
var result map[string]any
return result, goccyjson.Unmarshal(data, &result)
}
func (db *LineDb) normalizeDataArray(data any) []any { func (db *LineDb) normalizeDataArray(data any) []any {
switch v := data.(type) { switch v := data.(type) {
case []any: case []any:

View File

@@ -54,13 +54,50 @@ type LineDbInitOptions struct {
Partitions []PartitionCollection `json:"partitions,omitempty"` Partitions []PartitionCollection `json:"partitions,omitempty"`
} }
// EmptyValueMode определяет, что считать "пустым" при проверке required/unique
type EmptyValueMode uint
const (
EmptyModeAbsentKey EmptyValueMode = 1 << iota // ключ отсутствует в записи
EmptyModeNil // значение nil
EmptyModeZeroValue // "" для string, 0 для int/float; для bool не используется
)
// DefaultEmptyModeForRequired — режим по умолчанию для required (все виды пустоты)
const DefaultEmptyModeForRequired = EmptyModeAbsentKey | EmptyModeNil | EmptyModeZeroValue
// DefaultEmptyModeForUnique — режим по умолчанию для unique (nil и "" — пропускаем проверку)
const DefaultEmptyModeForUnique = EmptyModeNil | EmptyModeZeroValue
// FieldConstraint — конфигурация ограничения на поле (required/unique)
type FieldConstraint struct {
Name string `json:"name"`
Required bool `json:"required,omitempty"`
Unique bool `json:"unique,omitempty"`
EmptyMode EmptyValueMode `json:"emptyMode,omitempty"` // для required: что считать пустым; для unique: когда пропускать проверку
}
// KeyOrder задаёт порядок ключей при сериализации JSON.
// Order >= 0: с начала (0, 1, 2...). Order < 0: с конца (-1 = последний, -2 = предпоследний).
// Ключи не из списка — в середине, по алфавиту.
type KeyOrder struct {
Key string `json:"key"`
Order int `json:"order"`
}
// JSONLFileOptions представляет опции для JSONL файла // JSONLFileOptions представляет опции для JSONL файла
// Соответствует TypeScript интерфейсу JSONLFileOptions // Соответствует TypeScript интерфейсу JSONLFileOptions
type JSONLFileOptions struct { type JSONLFileOptions struct {
CollectionName string `json:"collectionName,omitempty"` CollectionName string `json:"collectionName,omitempty"`
AllocSize int `json:"allocSize,omitempty"` AllocSize int `json:"allocSize,omitempty"`
IndexedFields []string `json:"indexedFields,omitempty"` IndexedFields []string `json:"indexedFields,omitempty"`
UniqueFields []string `json:"uniqueFields,omitempty"` // Поля с ограничением уникальности FieldConstraints []FieldConstraint `json:"fieldConstraints,omitempty"` // Ограничения полей (required/unique)
UniqueFields []string `json:"uniqueFields,omitempty"` // Устаревшее: поля с уникальностью (совместимость)
KeyOrder []KeyOrder `json:"keyOrder,omitempty"` // Порядок ключей при сериализации (если задан — используется кастомная сериализация)
Encode bool `json:"encode,omitempty"` // Шифровать записи после сериализации
EncodeKey string `json:"encodeKey,omitempty"` // Ключ (строка -> SHA256 для байтового ключа), требуется при Encode
EncryptFn func(plaintext []byte, key []byte) ([]byte, error) `json:"-"` // Кастомное шифрование (если nil — встроенное AES-GCM)
DecryptFn func(ciphertext []byte, key []byte) ([]byte, error) `json:"-"` // Кастомная расшифровка (если nil — встроенное AES-GCM)
EncryptKeyForLineDb string `json:"encryptKeyForLineDb,omitempty"` EncryptKeyForLineDb string `json:"encryptKeyForLineDb,omitempty"`
SkipInvalidLines bool `json:"skipInvalidLines,omitempty"` SkipInvalidLines bool `json:"skipInvalidLines,omitempty"`
DecryptKey string `json:"decryptKey,omitempty"` DecryptKey string `json:"decryptKey,omitempty"`

BIN
realloc Executable file

Binary file not shown.

View File

@@ -1,7 +1,9 @@
package tests package tests
import ( import (
"log"
"os" "os"
"strings"
"testing" "testing"
"time" "time"
@@ -472,3 +474,280 @@ func TestLineDbUniqueFieldsNoUniqueFields(t *testing.T) {
t.Fatalf("Expected 2 records when no unique fields, got %d", len(all)) t.Fatalf("Expected 2 records when no unique fields, got %d", len(all))
} }
} }
// TestLineDbEncode проверяет шифрование записей при Encode
func TestLineDbEncode(t *testing.T) {
os.RemoveAll("./testdata")
initOptions := &linedb.LineDbInitOptions{
CacheSize: 100,
CacheTTL: time.Minute,
DBFolder: "./testdata",
Collections: []linedb.JSONLFileOptions{
{
CollectionName: "secret",
AllocSize: 512,
Encode: true,
EncodeKey: "test-secret-key",
},
},
}
db := linedb.NewLineDb(nil)
if err := db.Init(false, initOptions); err != nil {
t.Fatalf("Init failed: %v", err)
}
defer func() { db.Close(); os.RemoveAll("./testdata") }()
opts := linedb.LineDbAdapterOptions{}
if err := db.Insert(map[string]any{"name": "secret", "value": 42}, "secret", opts); err != nil {
t.Fatalf("Insert failed: %v", err)
}
all, err := db.Read("secret", opts)
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if len(all) != 1 {
t.Fatalf("Expected 1 record, got %d", len(all))
}
r := all[0].(map[string]any)
if r["name"] != "secret" || r["value"] != float64(42) {
t.Fatalf("Expected name=secret value=42, got %+v", r)
}
}
// TestLineDbAllocSizeOverflow проверяет ошибку при записи, превышающей allocSize-1
func TestLineDbAllocSizeOverflow(t *testing.T) {
os.RemoveAll("./testdata")
initOptions := &linedb.LineDbInitOptions{
CacheSize: 100,
CacheTTL: time.Minute,
DBFolder: "./testdata",
Collections: []linedb.JSONLFileOptions{
{CollectionName: "tiny", AllocSize: 64},
},
}
db := linedb.NewLineDb(nil)
if err := db.Init(false, initOptions); err != nil {
t.Fatalf("Init failed: %v", err)
}
defer func() { db.Close(); os.RemoveAll("./testdata") }()
opts := linedb.LineDbAdapterOptions{}
// Короткая запись — поместится (allocSize-1=63)
if err := db.Insert(map[string]any{"x": "short"}, "tiny", opts); err != nil {
t.Fatalf("Insert short failed: %v", err)
}
// Длинная — не поместится (JSON > 63 символов)
longStr := strings.Repeat("a", 80)
err := db.Insert(map[string]any{"x": longStr}, "tiny", opts)
if err == nil {
t.Fatal("Expected error for record exceeding allocSize-1")
}
if !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("Expected 'exceeds' in error, got: %v", err)
}
}
// TestLineDbInitNormalizeExistingFile проверяет нормализацию при Init существующего файла
func TestLineDbInitNormalizeExistingFile(t *testing.T) {
os.RemoveAll("./testdata")
os.MkdirAll("./testdata", 0755)
// Файл с записями разной длины
content := `{"id":1,"a":"x"}
{"id":2,"a":"yy"}
{"id":3,"a":"zzz"}`
if err := os.WriteFile("./testdata/norm.jsonl", []byte(content), 0644); err != nil {
t.Fatalf("Write file: %v", err)
}
defer os.RemoveAll("./testdata")
initOptions := &linedb.LineDbInitOptions{
CacheSize: 100,
CacheTTL: time.Minute,
DBFolder: "./testdata",
Collections: []linedb.JSONLFileOptions{
{CollectionName: "norm", AllocSize: 100},
},
}
db := linedb.NewLineDb(nil)
if err := db.Init(false, initOptions); err != nil {
t.Fatalf("Init failed: %v", err)
}
defer db.Close()
all, err := db.Read("norm", linedb.LineDbAdapterOptions{})
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if len(all) != 3 {
t.Fatalf("Expected 3 records, got %d", len(all))
}
}
// setupCollectionWithFieldConstraints создаёт БД с FieldConstraints (required/unique)
func setupCollectionWithFieldConstraints(t *testing.T, constraints []linedb.FieldConstraint) (*linedb.LineDb, func()) {
t.Helper()
os.RemoveAll("./testdata")
initOptions := &linedb.LineDbInitOptions{
CacheSize: 100,
CacheTTL: time.Minute,
DBFolder: "./testdata",
Collections: []linedb.JSONLFileOptions{
{
CollectionName: "items",
AllocSize: 256,
FieldConstraints: constraints,
},
},
}
db := linedb.NewLineDb(nil)
if err := db.Init(false, initOptions); err != nil {
t.Fatalf("Failed to init database: %v", err)
}
return db, func() { db.Close(); os.RemoveAll("./testdata") }
}
// TestLineDbRequiredFieldsInsert проверяет, что required поля обязательны при Insert
func TestLineDbRequiredFieldsInsert(t *testing.T) {
db, cleanup := setupCollectionWithFieldConstraints(t, []linedb.FieldConstraint{
{Name: "email", Required: true},
{Name: "name", Required: true},
})
defer cleanup()
opts := linedb.LineDbAdapterOptions{}
// Успех — оба поля заполнены
if err := db.Insert(map[string]any{"name": "alice", "email": "a@test.com"}, "items", opts); err != nil {
t.Fatalf("Insert with required fields failed: %v", err)
}
// Ошибка — отсутствует email (AbsentKey)
err := db.Insert(map[string]any{"name": "bob"}, "items", opts)
if err == nil {
t.Fatal("Expected error for missing required field email")
} else {
log.Println("Error: ", err)
}
// Ошибка — email = nil
err = db.Insert(map[string]any{"name": "bob", "email": nil}, "items", opts)
if err == nil {
t.Fatal("Expected error for nil required field email")
} else {
log.Println("Error: ", err)
}
// Ошибка — email = ""
err = db.Insert(map[string]any{"name": "bob", "email": ""}, "items", opts)
if err == nil {
t.Fatal("Expected error for empty string in required field email")
} else {
log.Println("Error: ", err)
}
all, _ := db.Read("items", opts)
if len(all) != 1 {
t.Fatalf("Expected 1 record, got %d", len(all))
}
}
// TestLineDbRequiredFieldsEmptyMode проверяет разные режимы интерпретации пустоты
func TestLineDbRequiredFieldsEmptyMode(t *testing.T) {
// Режим: только отсутствие ключа — nil и "" допустимы
db, cleanup := setupCollectionWithFieldConstraints(t, []linedb.FieldConstraint{
{Name: "title", Required: true, EmptyMode: linedb.EmptyModeAbsentKey},
})
defer cleanup()
opts := linedb.LineDbAdapterOptions{}
// nil — допустимо (только AbsentKey проверяется)
if err := db.Insert(map[string]any{"title": nil}, "items", opts); err != nil {
t.Fatalf("Insert with nil (AbsentKey only) failed: %v", err)
}
// "" — допустимо
if err := db.Insert(map[string]any{"title": ""}, "items", opts); err != nil {
t.Fatalf("Insert with empty string (AbsentKey only) failed: %v", err)
}
// Отсутствие ключа — ошибка (поле не в записи)
err := db.Insert(map[string]any{"other": "x"}, "items", opts)
if err == nil {
t.Fatal("Expected error for absent required key")
}
// Режим: только nil — AbsentKey и "" допустимы? Нет: AbsentKey при отсутствии — пусто.
// При EmptyModeNil: пусто только когда v==nil. AbsentKey при отсутствии ключа: exists=false.
// isEmptyByMode: if mode&EmptyModeAbsentKey && !exists return true. So if we use only EmptyModeNil,
// AbsentKey is not in the mask, so we don't return true for !exists. Good.
db2, cleanup2 := setupCollectionWithFieldConstraints(t, []linedb.FieldConstraint{
{Name: "flag", Required: true, EmptyMode: linedb.EmptyModeNil},
})
defer cleanup2()
// Ключ отсутствует — при EmptyModeNil мы не проверяем AbsentKey, так что !exists не даёт return true.
// isEmptyByMode: проверяет mode&EmptyModeAbsentKey - если нет, то !exists не приводит к true.
// Потом mode&EmptyModeNil && v==nil - но v при !exists будет нулевым при v, exists := m[key]. v = nil, exists = false.
// Но мы заходим в v, exists := m[key]. Если !exists, v будет nil (zero value). So v==nil is true.
// And mode&EmptyModeNil - if we have only Nil, we'd return true. So absent key would still be "empty" if we access v.
// Actually no - we first check mode&EmptyModeAbsentKey && !exists - if that's not in mode, we skip.
// Then mode&EmptyModeNil && v==nil - for absent key, v is nil. So we'd return true. So absent key + Nil mode would still fail.
// The logic is: we return true (empty) if ANY of the conditions matching the mode are true. So:
// - AbsentKey + !exists -> empty
// - Nil + v==nil -> empty
// - ZeroValue + ("" or 0) -> empty
// For EmptyModeNil only: we don't check AbsentKey. We check Nil - and for absent key, v is nil (zero value from map lookup). So we'd return true. So absent key would still be empty. Good.
// For EmptyModeZeroValue only: we'd only check ZeroValue. Absent key: v is nil, we don't have a case for nil in the switch (we'd fall through and return false). So absent key would NOT be empty. And nil would not be empty. Only "" and 0 would be.
// Let me add a test for EmptyModeZeroValue: required field, value 0 should fail, value "" should fail, but absent key... would not fail? That might be surprising. Let me document it.
if err := db2.Insert(map[string]any{"flag": false}, "items", opts); err != nil {
t.Fatalf("Insert with false (bool, Nil mode) failed: %v", err)
}
}
// TestLineDbRequiredFieldsUpdate проверяет, что Update не может установить пустое в required
func TestLineDbRequiredFieldsUpdate(t *testing.T) {
db, cleanup := setupCollectionWithFieldConstraints(t, []linedb.FieldConstraint{
{Name: "email", Required: true},
})
defer cleanup()
opts := linedb.LineDbAdapterOptions{}
if err := db.Insert(map[string]any{"name": "a", "email": "a@test.com"}, "items", opts); err != nil {
t.Fatalf("Insert failed: %v", err)
}
// Ошибка — пытаемся установить email в ""
_, err := db.Update(map[string]any{"email": ""}, "items", map[string]any{"email": "a@test.com"}, opts)
if err == nil {
t.Fatal("Expected error when setting required field to empty")
}
// Успех — обновляем другое поле
_, err = db.Update(map[string]any{"name": "alice"}, "items", map[string]any{"email": "a@test.com"}, opts)
if err != nil {
t.Fatalf("Update non-required field failed: %v", err)
}
}
// TestLineDbRequiredAndUniqueCombined проверяет комбинацию required и unique
func TestLineDbRequiredAndUniqueCombined(t *testing.T) {
db, cleanup := setupCollectionWithFieldConstraints(t, []linedb.FieldConstraint{
{Name: "email", Required: true, Unique: true},
})
defer cleanup()
opts := linedb.LineDbAdapterOptions{}
if err := db.Insert(map[string]any{"email": "x@test.com"}, "items", opts); err != nil {
t.Fatalf("Insert failed: %v", err)
}
err := db.Insert(map[string]any{"email": "x@test.com"}, "items", opts)
if err == nil {
t.Fatal("Expected unique violation")
}
err = db.Insert(map[string]any{"email": ""}, "items", opts)
if err == nil {
t.Fatal("Expected required violation for empty email")
}
}

639
tools/realloc/main.go Normal file
View File

@@ -0,0 +1,639 @@
package main
import (
"bufio"
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type fileStats struct {
Path string
LineCount int
NonEmptyCount int
Lengths map[int]int
MinLen int
MaxLen int
MaxDataLen int
ProbablyBase64 bool
// JSON-object stats (only when ProbablyBase64 == false)
JSONObjects int
JSONParseErrors int
MinFieldCount int
MaxFieldCount int
AllKeysSame bool
AllFieldCountsSame bool
SigOverflow bool
SigCounts map[string]int
SigExampleKeys map[string][]string
}
const (
colorReset = "\x1b[0m"
colorRed = "\x1b[31m"
colorGreen = "\x1b[32m"
colorYellow = "\x1b[33m"
colorCyan = "\x1b[36m"
colorBold = "\x1b[1m"
)
func main() {
if len(os.Args) < 2 {
fmt.Printf("%sUsage:%s realloc <directory-with-jsonl-files>\n", colorCyan, colorReset)
os.Exit(1)
}
dir := os.Args[1]
if err := run(dir); err != nil {
fmt.Fprintf(os.Stderr, "%sError:%s %v\n", colorRed, colorReset, err)
os.Exit(1)
}
}
func run(dir string) error {
info, err := os.Stat(dir)
if err != nil {
return fmt.Errorf("stat dir: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", dir)
}
files, err := filepath.Glob(filepath.Join(dir, "*.jsonl"))
if err != nil {
return fmt.Errorf("glob: %w", err)
}
if len(files) == 0 {
fmt.Printf("%sNo *.jsonl files found in%s %s\n", colorYellow, colorReset, dir)
return nil
}
fmt.Printf("%sFound JSONL files:%s\n", colorCyan, colorReset)
for i, f := range files {
fmt.Printf(" [%d] %s\n", i+1, filepath.Base(f))
}
reader := bufio.NewReader(os.Stdin)
fmt.Printf("\n%sEnter file index or name (empty to cancel): %s", colorCyan, colorReset)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
fmt.Printf("%sCancelled.%s\n", colorYellow, colorReset)
return nil
}
var path string
if idx, err := strconv.Atoi(input); err == nil && idx >= 1 && idx <= len(files) {
path = files[idx-1]
} else {
// try match by name
for _, f := range files {
if filepath.Base(f) == input {
path = f
break
}
}
if path == "" {
return fmt.Errorf("file %q not found in %s", input, dir)
}
}
stats, err := analyzeFile(path)
if err != nil {
return err
}
// Если файл похож на закодированный — спросим ключ и попробуем расшифровать для статистики по полям
if stats.ProbablyBase64 {
fmt.Printf("\n%sFile looks encoded. If it was encrypted with Encode/EncodeKey, you can enter the key to analyze JSON fields.%s\n", colorYellow, colorReset)
fmt.Printf("%sEncode key (press Enter to skip): %s", colorCyan, colorReset)
keyLine, _ := reader.ReadString('\n')
keyLine = strings.TrimSpace(keyLine)
if keyLine != "" {
if err := fillDecryptedJSONStats(path, keyLine, stats); err != nil {
fmt.Fprintf(os.Stderr, "%sDecrypt/JSON analyze error:%s %v\n", colorRed, colorReset, err)
}
}
} else {
// Плоский JSON — сразу считаем статистику полей
if err := fillDecryptedJSONStats(path, "", stats); err != nil {
fmt.Fprintf(os.Stderr, "%sJSON analyze error:%s %v\n", colorRed, colorReset, err)
}
}
printStats(stats)
// Suggest allocSize
recommendedAlloc := stats.MaxLen + 1
fmt.Printf("\n%sSuggested allocSize%s (from max line length %d): %s%d%s\n",
colorCyan, colorReset, stats.MaxLen, colorGreen, recommendedAlloc, colorReset)
fmt.Printf("%sEnter new allocSize%s (empty to skip, 0 to use suggested): ", colorCyan, colorReset)
line, _ := reader.ReadString('\n')
line = strings.TrimSpace(line)
if line == "" {
fmt.Printf("%sSkipped realloc.%s\n", colorYellow, colorReset)
return nil
}
var targetAlloc int
if line == "0" {
targetAlloc = recommendedAlloc
} else {
val, err := strconv.Atoi(line)
if err != nil || val < 2 {
return fmt.Errorf("invalid allocSize: %q", line)
}
targetAlloc = val
}
if err := reallocFile(path, stats, targetAlloc); err != nil {
return err
}
fmt.Printf("%sRealloc completed.%s File updated: %s\n", colorGreen, colorReset, path)
return nil
}
func analyzeFile(path string) (*fileStats, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
lines := strings.Split(string(data), "\n")
stats := &fileStats{
Path: path,
LineCount: len(lines),
Lengths: make(map[int]int),
MinLen: 0,
MaxLen: 0,
MaxDataLen: 0,
SigCounts: make(map[string]int),
SigExampleKeys: make(map[string][]string),
}
var nonEmpty []string
for _, l := range lines {
if l == "" {
continue
}
nonEmpty = append(nonEmpty, l)
}
stats.NonEmptyCount = len(nonEmpty)
if len(nonEmpty) == 0 {
return stats, nil
}
// эвристика: если у значащих данных нет { ... } по краям, считаем, что это закодированное содержимое
encodedCandidates := 0
for _, l := range nonEmpty {
n := len(l)
stats.Lengths[n]++
if stats.MinLen == 0 || n < stats.MinLen {
stats.MinLen = n
}
if n > stats.MaxLen {
stats.MaxLen = n
}
trimmed := strings.TrimRight(l, " ")
if len(trimmed) > stats.MaxDataLen {
stats.MaxDataLen = len(trimmed)
}
// Значащие данные без { } по краям — считаем как "encoded"
core := strings.TrimSpace(l)
if core != "" && !(strings.HasPrefix(core, "{") && strings.HasSuffix(core, "}")) {
encodedCandidates++
}
}
if stats.NonEmptyCount > 0 && encodedCandidates >= stats.NonEmptyCount/2 {
stats.ProbablyBase64 = true
}
// Доп.статистика по JSON-полям — только если это не похоже на закодированные строки
if !stats.ProbablyBase64 {
stats.MinFieldCount = 0
stats.MaxFieldCount = 0
stats.AllKeysSame = true
stats.AllFieldCountsSame = true
var refSig string
var refCount int
for _, l := range nonEmpty {
core := strings.TrimSpace(strings.TrimRight(l, " "))
if core == "" {
continue
}
// ожидаем JSON object
if !(strings.HasPrefix(core, "{") && strings.HasSuffix(core, "}")) {
stats.JSONParseErrors++
continue
}
var obj map[string]any
if err := json.Unmarshal([]byte(core), &obj); err != nil {
stats.JSONParseErrors++
continue
}
stats.JSONObjects++
cnt := len(obj)
if stats.MinFieldCount == 0 || cnt < stats.MinFieldCount {
stats.MinFieldCount = cnt
}
if cnt > stats.MaxFieldCount {
stats.MaxFieldCount = cnt
}
keys := make([]string, 0, cnt)
for k := range obj {
keys = append(keys, k)
}
sortStrings(keys)
sig := strings.Join(keys, "\x1f") // case-sensitive
if refSig == "" {
refSig = sig
refCount = cnt
} else {
if sig != refSig {
stats.AllKeysSame = false
}
if cnt != refCount {
stats.AllFieldCountsSame = false
}
}
// collect limited signature stats
if _, ok := stats.SigCounts[sig]; ok || len(stats.SigCounts) < 10 {
stats.SigCounts[sig]++
if _, ok := stats.SigExampleKeys[sig]; !ok {
stats.SigExampleKeys[sig] = keys
}
} else {
stats.SigOverflow = true
}
}
// If no objects parsed, keep flags meaningful
if stats.JSONObjects == 0 {
stats.AllKeysSame = false
stats.AllFieldCountsSame = false
} else if stats.MinFieldCount != stats.MaxFieldCount {
stats.AllFieldCountsSame = false
}
}
return stats, nil
}
// encodeKeyBytes превращает строку ключа в 32 байта (SHA256) для AES-256
func encodeKeyBytes(keyStr string) []byte {
h := sha256.Sum256([]byte(keyStr))
return h[:]
}
// aesGCMDecrypt — расшифровка AES-256-GCM (nonce||ciphertext)
func aesGCMDecrypt(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := aesgcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, data := ciphertext[:nonceSize], ciphertext[nonceSize:]
return aesgcm.Open(nil, nonce, data, nil)
}
// fillDecryptedJSONStats заполняет JSON-статистику.
// Если keyStr == "", предполагается, что строки содержат обычный JSON.
// Если keyStr != "", строки считаются base64(AES-GCM(JSON)).
func fillDecryptedJSONStats(path, keyStr string, stats *fileStats) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
// сбрасываем предыдущие JSON-статы
stats.JSONObjects = 0
stats.JSONParseErrors = 0
stats.MinFieldCount = 0
stats.MaxFieldCount = 0
stats.AllKeysSame = true
stats.AllFieldCountsSame = true
stats.SigOverflow = false
stats.SigCounts = make(map[string]int)
stats.SigExampleKeys = make(map[string][]string)
var key []byte
if keyStr != "" {
key = encodeKeyBytes(keyStr)
}
var refSig string
var refCount int
for _, l := range lines {
if l == "" {
continue
}
raw := strings.TrimRight(l, " ")
if raw == "" {
continue
}
var payload []byte
if key != nil {
enc, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil {
stats.JSONParseErrors++
continue
}
plain, err := aesGCMDecrypt(enc, key)
if err != nil {
stats.JSONParseErrors++
continue
}
payload = plain
} else {
payload = []byte(strings.TrimSpace(raw))
}
core := strings.TrimSpace(string(payload))
if core == "" {
continue
}
if !(strings.HasPrefix(core, "{") && strings.HasSuffix(core, "}")) {
stats.JSONParseErrors++
continue
}
var obj map[string]any
if err := json.Unmarshal([]byte(core), &obj); err != nil {
stats.JSONParseErrors++
continue
}
stats.JSONObjects++
cnt := len(obj)
if stats.MinFieldCount == 0 || cnt < stats.MinFieldCount {
stats.MinFieldCount = cnt
}
if cnt > stats.MaxFieldCount {
stats.MaxFieldCount = cnt
}
keys := make([]string, 0, cnt)
for k := range obj {
keys = append(keys, k)
}
sortStrings(keys)
sig := strings.Join(keys, "\x1f")
if refSig == "" {
refSig = sig
refCount = cnt
} else {
if sig != refSig {
stats.AllKeysSame = false
}
if cnt != refCount {
stats.AllFieldCountsSame = false
}
}
if _, ok := stats.SigCounts[sig]; ok || len(stats.SigCounts) < 10 {
stats.SigCounts[sig]++
if _, ok := stats.SigExampleKeys[sig]; !ok {
stats.SigExampleKeys[sig] = keys
}
} else {
stats.SigOverflow = true
}
}
if stats.JSONObjects == 0 {
stats.AllKeysSame = false
stats.AllFieldCountsSame = false
} else if stats.MinFieldCount != stats.MaxFieldCount {
stats.AllFieldCountsSame = false
}
return nil
}
func printStats(s *fileStats) {
fmt.Printf("\n%sStats for%s %s%s%s\n", colorBold, colorReset, colorCyan, s.Path, colorReset)
fmt.Printf(" Total lines: %d\n", s.LineCount)
fmt.Printf(" Non-empty lines: %d\n", s.NonEmptyCount)
fmt.Printf(" Min line length: %d\n", s.MinLen)
fmt.Printf(" Max line length: %d\n", s.MaxLen)
fmt.Printf(" Max data length: %d (without trailing spaces)\n", s.MaxDataLen)
fmt.Printf(" Unique lengths: ")
first := true
for l, c := range s.Lengths {
if !first {
fmt.Print(", ")
}
first = false
fmt.Printf("%d(%d)", l, c)
}
fmt.Println()
if s.ProbablyBase64 {
fmt.Printf(" Encoded: %slikely encoded%s (no { } around data)\n", colorYellow, colorReset)
// Если удалось расшифровать/распарсить JSON — покажем статистику по полям
if s.JSONObjects > 0 || s.JSONParseErrors > 0 {
fmt.Printf(" Decoded JSON: %s%s%s\n", colorCyan, "analysis", colorReset)
fmt.Printf(" JSON objects: %d\n", s.JSONObjects)
if s.JSONParseErrors > 0 {
fmt.Printf(" JSON errors: %s%d%s\n", colorYellow, s.JSONParseErrors, colorReset)
} else {
fmt.Printf(" JSON errors: %s0%s\n", colorGreen, colorReset)
}
if s.JSONObjects > 0 {
fmt.Printf(" Fields per rec: min=%d max=%d\n", s.MinFieldCount, s.MaxFieldCount)
if s.AllFieldCountsSame {
fmt.Printf(" Field counts: %sall equal%s\n", colorGreen, colorReset)
} else {
fmt.Printf(" Field counts: %sNOT equal%s\n", colorRed, colorReset)
}
if s.AllKeysSame {
fmt.Printf(" Field names: %sall equal%s\n", colorGreen, colorReset)
} else {
fmt.Printf(" Field names: %sNOT equal%s\n", colorRed, colorReset)
}
if len(s.SigCounts) > 0 {
schemaCount := len(s.SigCounts)
extra := ""
if s.SigOverflow {
extra = " (showing first 10)"
}
color := colorGreen
if schemaCount > 1 {
color = colorYellow
}
fmt.Printf(" Schemas: %s%d%s%s\n", color, schemaCount, colorReset, extra)
type kv struct {
sig string
count int
}
var list []kv
for sig, c := range s.SigCounts {
list = append(list, kv{sig: sig, count: c})
}
if len(list) > 0 {
ref := list[0]
refKeys := s.SigExampleKeys[ref.sig]
fmt.Printf(" Ref schema keys (%s%d%s recs): %v\n", colorCyan, ref.count, colorReset, refKeys)
for _, kv := range list[1:] {
if kv.sig != ref.sig {
diffKeys := s.SigExampleKeys[kv.sig]
fmt.Printf(" Diff schema keys (%s%d%s recs): %v\n", colorYellow, kv.count, colorReset, diffKeys)
break
}
}
}
}
}
}
} else {
fmt.Printf(" Encoded: %slooks like plain JSON%s\n", colorGreen, colorReset)
fmt.Printf(" JSON objects: %d\n", s.JSONObjects)
if s.JSONParseErrors > 0 {
fmt.Printf(" JSON errors: %s%d%s\n", colorYellow, s.JSONParseErrors, colorReset)
} else {
fmt.Printf(" JSON errors: %s0%s\n", colorGreen, colorReset)
}
if s.JSONObjects > 0 {
fmt.Printf(" Fields per rec: min=%d max=%d\n", s.MinFieldCount, s.MaxFieldCount)
if s.AllFieldCountsSame {
fmt.Printf(" Field counts: %sall equal%s\n", colorGreen, colorReset)
} else {
fmt.Printf(" Field counts: %sNOT equal%s\n", colorRed, colorReset)
}
if s.AllKeysSame {
fmt.Printf(" Field names: %sall equal%s\n", colorGreen, colorReset)
} else {
fmt.Printf(" Field names: %sNOT equal%s\n", colorRed, colorReset)
}
if len(s.SigCounts) > 0 {
schemaCount := len(s.SigCounts)
extra := ""
if s.SigOverflow {
extra = " (showing first 10)"
}
color := colorGreen
if schemaCount > 1 {
color = colorYellow
}
fmt.Printf(" Schemas: %s%d%s%s\n", color, schemaCount, colorReset, extra)
// Показать пример эталонной и отличающейся схем
type kv struct {
sig string
count int
}
var list []kv
for sig, c := range s.SigCounts {
list = append(list, kv{sig: sig, count: c})
}
// не сортируем по алфавиту, просто первый будет эталоном
if len(list) > 0 {
ref := list[0]
refKeys := s.SigExampleKeys[ref.sig]
fmt.Printf(" Ref schema keys (%s%d%s recs): %v\n", colorCyan, ref.count, colorReset, refKeys)
// найдём первую отличающуюся схему
for _, kv := range list[1:] {
if kv.sig != ref.sig {
diffKeys := s.SigExampleKeys[kv.sig]
fmt.Printf(" Diff schema keys (%s%d%s recs): %v\n", colorYellow, kv.count, colorReset, diffKeys)
break
}
}
}
}
}
}
}
func sortStrings(a []string) {
// simple insertion sort to avoid extra deps
for i := 1; i < len(a); i++ {
j := i
for j > 0 && a[j-1] > a[j] {
a[j-1], a[j] = a[j], a[j-1]
j--
}
}
}
func reallocFile(path string, stats *fileStats, newAlloc int) error {
if stats.NonEmptyCount == 0 {
fmt.Printf("%sFile is empty, nothing to realloc.%s\n", colorYellow, colorReset)
return nil
}
targetLen := newAlloc - 1
if targetLen < 1 {
return fmt.Errorf("target allocSize too small")
}
if stats.MaxDataLen > targetLen {
return fmt.Errorf("cannot set allocSize=%d: max data length %d would be truncated", newAlloc, stats.MaxDataLen)
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
var buf bytes.Buffer
for _, l := range lines {
if l == "" {
continue
}
trimmed := strings.TrimRight(l, " ")
if trimmed == "" {
// строка содержит только пробелы — считаем удалённой и пропускаем
continue
}
if len(trimmed) > targetLen {
return fmt.Errorf("line data length %d exceeds targetLen %d", len(trimmed), targetLen)
}
padded := trimmed + strings.Repeat(" ", targetLen-len(trimmed))
buf.WriteString(padded)
buf.WriteByte('\n')
}
// backup
backup := path + ".bak"
if err := os.WriteFile(backup, data, 0644); err != nil {
return fmt.Errorf("write backup: %w", err)
}
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
return fmt.Errorf("write new file: %w", err)
}
fmt.Printf("%sBackup saved%s to %s\n", colorGreen, colorReset, backup)
return nil
}