Sync telesrv commits 49f9bab and 04d9563 into the public mirror. Exclude private docs and runtime key material per sync rules.
467 lines
16 KiB
Go
467 lines
16 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store/postgres/sqlcgen"
|
|
)
|
|
|
|
// LangPackStore 用 PostgreSQL 实现 store.LangPackStore。
|
|
type LangPackStore struct {
|
|
db sqlcgen.DBTX
|
|
q *sqlcgen.Queries
|
|
}
|
|
|
|
// NewLangPackStore 基于 pgx 连接池(或事务)创建 LangPackStore。
|
|
func NewLangPackStore(db sqlcgen.DBTX) *LangPackStore {
|
|
return &LangPackStore{db: db, q: sqlcgen.New(db)}
|
|
}
|
|
|
|
func (s *LangPackStore) GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
|
|
meta, found, err := s.meta(ctx, langPack, langCode)
|
|
if err != nil || !found {
|
|
return meta, err
|
|
}
|
|
meta.FromVersion = fromVersion
|
|
if meta.Version <= fromVersion {
|
|
return meta, nil
|
|
}
|
|
rows, err := s.q.ListLangPackStrings(ctx, sqlcgen.ListLangPackStringsParams{
|
|
LangPack: langPack,
|
|
LangCode: langCode,
|
|
})
|
|
if err != nil {
|
|
return domain.LangPack{}, fmt.Errorf("list lang pack strings: %w", err)
|
|
}
|
|
meta.Strings = make([]domain.LangPackString, 0, len(rows))
|
|
for _, row := range rows {
|
|
meta.Strings = append(meta.Strings, langPackStringFromListRow(row))
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
func (s *LangPackStore) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) {
|
|
meta, found, err := s.meta(ctx, langPack, langCode)
|
|
if err != nil || !found {
|
|
return meta, err
|
|
}
|
|
if len(keys) == 0 {
|
|
return s.GetPack(ctx, langPack, langCode, 0)
|
|
}
|
|
rows, err := s.q.GetLangPackStringsByKeys(ctx, sqlcgen.GetLangPackStringsByKeysParams{
|
|
LangPack: langPack,
|
|
LangCode: langCode,
|
|
Keys: keys,
|
|
})
|
|
if err != nil {
|
|
return domain.LangPack{}, fmt.Errorf("get lang pack strings: %w", err)
|
|
}
|
|
meta.Strings = make([]domain.LangPackString, 0, len(rows))
|
|
for _, row := range rows {
|
|
meta.Strings = append(meta.Strings, langPackStringFromKeysRow(row))
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
func (s *LangPackStore) ListLanguages(ctx context.Context, langPack string) ([]domain.LangPackLanguage, error) {
|
|
rows, err := s.q.ListLangPackLanguages(ctx, langPack)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list lang pack languages: %w", err)
|
|
}
|
|
out := make([]domain.LangPackLanguage, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, domain.LangPackLanguage{
|
|
LangPack: row.LangPack,
|
|
LangCode: row.LangCode,
|
|
Name: row.Name,
|
|
NativeName: row.NativeName,
|
|
StringsCount: int(row.StringsCount),
|
|
TranslatedCount: int(row.StringsCount),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *LangPackStore) GetSeedCatalog(ctx context.Context, catalog string) (domain.LangPackSeedCatalog, error) {
|
|
if catalog == "" {
|
|
catalog = "default"
|
|
}
|
|
encoded, err := s.q.GetLangPackSeedHash(ctx, langPackSeedManifestStateKey(catalog))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.LangPackSeedCatalog{Catalog: catalog}, nil
|
|
}
|
|
if err != nil {
|
|
return domain.LangPackSeedCatalog{}, fmt.Errorf("get langpack seed catalog %q: %w", catalog, err)
|
|
}
|
|
var state domain.LangPackSeedCatalog
|
|
if err := json.Unmarshal([]byte(encoded), &state); err != nil {
|
|
return domain.LangPackSeedCatalog{}, fmt.Errorf("decode langpack seed catalog %q: %w", catalog, err)
|
|
}
|
|
if state.Catalog != catalog {
|
|
return domain.LangPackSeedCatalog{}, fmt.Errorf("langpack seed catalog key %q contains catalog %q", catalog, state.Catalog)
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
func (s *LangPackStore) ReconcileSeed(ctx context.Context, seed domain.LangPackSeed) (int, error) {
|
|
txer, ok := s.db.(interface {
|
|
Begin(context.Context) (pgx.Tx, error)
|
|
})
|
|
if !ok {
|
|
return 0, errors.New("langpack seed reconciliation requires transaction support")
|
|
}
|
|
tx, err := txer.Begin(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("begin langpack seed reconciliation: %w", err)
|
|
}
|
|
written, err := reconcileSeedWith(ctx, tx, s.q.WithTx(tx), seed)
|
|
if err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
return 0, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return 0, fmt.Errorf("commit langpack seed reconciliation: %w", err)
|
|
}
|
|
return written, nil
|
|
}
|
|
|
|
func (s *LangPackStore) UpsertPack(ctx context.Context, pack domain.LangPack) error {
|
|
if txer, ok := s.db.(interface {
|
|
Begin(context.Context) (pgx.Tx, error)
|
|
}); ok {
|
|
tx, err := txer.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin lang pack upsert: %w", err)
|
|
}
|
|
q := s.q.WithTx(tx)
|
|
if err := replacePackWithCopy(ctx, tx, q, pack); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
return err
|
|
}
|
|
if err := q.DeleteLangPackSeedHash(ctx, langPackSeedStateKey(pack.LangPack, pack.LangCode)); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
return fmt.Errorf("delete lang pack seed hash: %w", err)
|
|
}
|
|
if err := invalidateLangPackSeedCatalogs(ctx, tx); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
return err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit lang pack upsert: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
if err := replacePackWithQueries(ctx, s.q, pack); err != nil {
|
|
return err
|
|
}
|
|
if err := s.q.DeleteLangPackSeedHash(ctx, langPackSeedStateKey(pack.LangPack, pack.LangCode)); err != nil {
|
|
return fmt.Errorf("delete lang pack seed hash: %w", err)
|
|
}
|
|
if err := invalidateLangPackSeedCatalogs(ctx, s.db); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func reconcileSeedWith(ctx context.Context, tx pgx.Tx, q *sqlcgen.Queries, seed domain.LangPackSeed) (int, error) {
|
|
catalog := seed.Catalog
|
|
if catalog == "" {
|
|
catalog = "default"
|
|
}
|
|
reconciledScopes := append([]string(nil), seed.Scopes...)
|
|
scopes := make(map[string]struct{}, len(seed.Scopes))
|
|
wanted := make(map[string]map[string]struct{}, len(seed.Scopes))
|
|
for _, scope := range seed.Scopes {
|
|
scopes[scope] = struct{}{}
|
|
wanted[scope] = make(map[string]struct{})
|
|
}
|
|
previousScopesJSON, err := q.GetLangPackSeedHash(ctx, langPackSeedCatalogStateKey(catalog))
|
|
if err == nil {
|
|
var previousScopes []string
|
|
if err := json.Unmarshal([]byte(previousScopesJSON), &previousScopes); err != nil {
|
|
return 0, fmt.Errorf("decode previous langpack seed scopes for %q: %w", catalog, err)
|
|
}
|
|
for _, scope := range previousScopes {
|
|
if _, exists := scopes[scope]; exists {
|
|
continue
|
|
}
|
|
scopes[scope] = struct{}{}
|
|
wanted[scope] = make(map[string]struct{})
|
|
reconciledScopes = append(reconciledScopes, scope)
|
|
}
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, fmt.Errorf("get previous langpack seed scopes for %q: %w", catalog, err)
|
|
}
|
|
for _, entry := range seed.Packs {
|
|
if err := validateSeedEntry(entry); err != nil {
|
|
return 0, err
|
|
}
|
|
if _, ok := scopes[entry.Pack.LangPack]; !ok {
|
|
return 0, fmt.Errorf("seeded langpack %s/%s is outside reconciliation scopes", entry.Pack.LangPack, entry.Pack.LangCode)
|
|
}
|
|
if _, exists := wanted[entry.Pack.LangPack][entry.Pack.LangCode]; exists {
|
|
return 0, fmt.Errorf("duplicate seeded langpack %s/%s", entry.Pack.LangPack, entry.Pack.LangCode)
|
|
}
|
|
wanted[entry.Pack.LangPack][entry.Pack.LangCode] = struct{}{}
|
|
}
|
|
|
|
for _, scope := range reconciledScopes {
|
|
codes, err := q.ListLangPackCodes(ctx, scope)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("list existing langpack codes for %q: %w", scope, err)
|
|
}
|
|
for _, code := range codes {
|
|
if _, keep := wanted[scope][code]; keep {
|
|
continue
|
|
}
|
|
params := sqlcgen.DeleteLangPackStringsParams{LangPack: scope, LangCode: code}
|
|
if err := q.DeleteLangPackStrings(ctx, params); err != nil {
|
|
return 0, fmt.Errorf("delete removed langpack strings %s/%s: %w", scope, code, err)
|
|
}
|
|
if err := q.DeleteLangPackMeta(ctx, sqlcgen.DeleteLangPackMetaParams(params)); err != nil {
|
|
return 0, fmt.Errorf("delete removed langpack metadata %s/%s: %w", scope, code, err)
|
|
}
|
|
if err := q.DeleteLangPackSeedHash(ctx, langPackSeedStateKey(scope, code)); err != nil {
|
|
return 0, fmt.Errorf("delete removed langpack seed hash %s/%s: %w", scope, code, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
written := 0
|
|
for _, entry := range seed.Packs {
|
|
pack := entry.Pack
|
|
meta, metaErr := q.GetLangPackMeta(ctx, sqlcgen.GetLangPackMetaParams{
|
|
LangPack: pack.LangPack,
|
|
LangCode: pack.LangCode,
|
|
})
|
|
metaFound := metaErr == nil
|
|
if metaErr != nil && !errors.Is(metaErr, pgx.ErrNoRows) {
|
|
return 0, fmt.Errorf("get existing langpack metadata %s/%s: %w", pack.LangPack, pack.LangCode, metaErr)
|
|
}
|
|
if metaFound && pack.Version < int(meta.Version) {
|
|
return 0, fmt.Errorf("langpack version rollback %s/%s: %d < %d", pack.LangPack, pack.LangCode, pack.Version, meta.Version)
|
|
}
|
|
|
|
stateKey := langPackSeedStateKey(pack.LangPack, pack.LangCode)
|
|
oldHash, hashErr := q.GetLangPackSeedHash(ctx, stateKey)
|
|
hashFound := hashErr == nil
|
|
if hashErr != nil && !errors.Is(hashErr, pgx.ErrNoRows) {
|
|
return 0, fmt.Errorf("get langpack seed hash %s/%s: %w", pack.LangPack, pack.LangCode, hashErr)
|
|
}
|
|
if metaFound && hashFound && pack.Version == int(meta.Version) && oldHash != entry.ContentHash {
|
|
return 0, fmt.Errorf("langpack %s/%s v%d content changed without version bump", pack.LangPack, pack.LangCode, pack.Version)
|
|
}
|
|
if metaFound && hashFound && pack.Version == int(meta.Version) && int(meta.StringsCount) == entry.StringsCount && oldHash == entry.ContentHash {
|
|
continue
|
|
}
|
|
if !entry.ContentLoaded {
|
|
return 0, fmt.Errorf("langpack %s/%s content is required but was not loaded", pack.LangPack, pack.LangCode)
|
|
}
|
|
|
|
if err := replacePackWithCopy(ctx, tx, q, pack); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := q.PutLangPackSeedHash(ctx, sqlcgen.PutLangPackSeedHashParams{Key: stateKey, ContentHash: entry.ContentHash}); err != nil {
|
|
return 0, fmt.Errorf("put langpack seed hash %s/%s: %w", pack.LangPack, pack.LangCode, err)
|
|
}
|
|
written += len(pack.Strings)
|
|
}
|
|
encodedScopes, err := json.Marshal(seed.Scopes)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("encode langpack seed scopes for %q: %w", catalog, err)
|
|
}
|
|
if err := q.PutLangPackSeedHash(ctx, sqlcgen.PutLangPackSeedHashParams{
|
|
Key: langPackSeedCatalogStateKey(catalog),
|
|
ContentHash: string(encodedScopes),
|
|
}); err != nil {
|
|
return 0, fmt.Errorf("put langpack seed scopes for %q: %w", catalog, err)
|
|
}
|
|
manifest := seedCatalogSnapshot(seed)
|
|
encodedManifest, err := json.Marshal(manifest)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("encode langpack seed catalog %q: %w", catalog, err)
|
|
}
|
|
if err := q.PutLangPackSeedHash(ctx, sqlcgen.PutLangPackSeedHashParams{
|
|
Key: langPackSeedManifestStateKey(catalog),
|
|
ContentHash: string(encodedManifest),
|
|
}); err != nil {
|
|
return 0, fmt.Errorf("put langpack seed catalog %q: %w", catalog, err)
|
|
}
|
|
return written, nil
|
|
}
|
|
|
|
func replacePackWithCopy(ctx context.Context, tx pgx.Tx, q *sqlcgen.Queries, pack domain.LangPack) error {
|
|
params := sqlcgen.DeleteLangPackStringsParams{LangPack: pack.LangPack, LangCode: pack.LangCode}
|
|
if err := q.DeleteLangPackStrings(ctx, params); err != nil {
|
|
return fmt.Errorf("delete previous lang pack strings: %w", err)
|
|
}
|
|
if err := q.UpsertLangPackMeta(ctx, sqlcgen.UpsertLangPackMetaParams{
|
|
LangPack: pack.LangPack,
|
|
LangCode: pack.LangCode,
|
|
Version: int32(pack.Version),
|
|
StringsCount: int32(len(pack.Strings)),
|
|
}); err != nil {
|
|
return fmt.Errorf("upsert lang pack meta: %w", err)
|
|
}
|
|
if len(pack.Strings) == 0 {
|
|
return nil
|
|
}
|
|
count, err := tx.CopyFrom(
|
|
ctx,
|
|
pgx.Identifier{"lang_pack_strings"},
|
|
[]string{
|
|
"lang_pack", "lang_code", "key", "version", "pluralized", "value",
|
|
"zero_value", "one_value", "two_value", "few_value", "many_value", "other_value", "deleted",
|
|
},
|
|
pgx.CopyFromSlice(len(pack.Strings), func(i int) ([]any, error) {
|
|
item := pack.Strings[i]
|
|
return []any{
|
|
pack.LangPack, pack.LangCode, item.Key, int32(pack.Version), item.Pluralized, item.Value,
|
|
item.ZeroValue, item.OneValue, item.TwoValue, item.FewValue, item.ManyValue, item.OtherValue, item.Deleted,
|
|
}, nil
|
|
}),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("copy lang pack strings: %w", err)
|
|
}
|
|
if count != int64(len(pack.Strings)) {
|
|
return fmt.Errorf("copy lang pack strings wrote %d rows, want %d", count, len(pack.Strings))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func replacePackWithQueries(ctx context.Context, q *sqlcgen.Queries, pack domain.LangPack) error {
|
|
if err := q.DeleteLangPackStrings(ctx, sqlcgen.DeleteLangPackStringsParams{LangPack: pack.LangPack, LangCode: pack.LangCode}); err != nil {
|
|
return fmt.Errorf("delete previous lang pack strings: %w", err)
|
|
}
|
|
if err := q.UpsertLangPackMeta(ctx, sqlcgen.UpsertLangPackMetaParams{
|
|
LangPack: pack.LangPack,
|
|
LangCode: pack.LangCode,
|
|
Version: int32(pack.Version),
|
|
StringsCount: int32(len(pack.Strings)),
|
|
}); err != nil {
|
|
return fmt.Errorf("upsert lang pack meta: %w", err)
|
|
}
|
|
for _, item := range pack.Strings {
|
|
if err := q.UpsertLangPackString(ctx, sqlcgen.UpsertLangPackStringParams{
|
|
LangPack: pack.LangPack,
|
|
LangCode: pack.LangCode,
|
|
Key: item.Key,
|
|
Version: int32(pack.Version),
|
|
Pluralized: item.Pluralized,
|
|
Value: item.Value,
|
|
ZeroValue: item.ZeroValue,
|
|
OneValue: item.OneValue,
|
|
TwoValue: item.TwoValue,
|
|
FewValue: item.FewValue,
|
|
ManyValue: item.ManyValue,
|
|
OtherValue: item.OtherValue,
|
|
Deleted: item.Deleted,
|
|
}); err != nil {
|
|
return fmt.Errorf("upsert lang pack string %q: %w", item.Key, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func langPackSeedStateKey(langPack, langCode string) string {
|
|
return "langpack:v1:entry:" + langPack + ":" + langCode
|
|
}
|
|
|
|
func langPackSeedCatalogStateKey(catalog string) string {
|
|
return "langpack:v1:catalog:" + catalog
|
|
}
|
|
|
|
func langPackSeedManifestStateKey(catalog string) string {
|
|
return "langpack:v2:catalog:" + catalog
|
|
}
|
|
|
|
func validateSeedEntry(entry domain.LangPackSeedEntry) error {
|
|
if entry.SourceHash == "" || entry.ContentHash == "" || entry.StringsCount <= 0 {
|
|
return fmt.Errorf("langpack %s/%s has incomplete seed metadata", entry.Pack.LangPack, entry.Pack.LangCode)
|
|
}
|
|
if entry.ContentLoaded && len(entry.Pack.Strings) != entry.StringsCount {
|
|
return fmt.Errorf("langpack %s/%s loaded %d strings, want %d", entry.Pack.LangPack, entry.Pack.LangCode, len(entry.Pack.Strings), entry.StringsCount)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func seedCatalogSnapshot(seed domain.LangPackSeed) domain.LangPackSeedCatalog {
|
|
state := domain.LangPackSeedCatalog{
|
|
Catalog: seed.Catalog,
|
|
Scopes: append([]string(nil), seed.Scopes...),
|
|
Packs: make([]domain.LangPackSeedCatalogEntry, 0, len(seed.Packs)),
|
|
}
|
|
for _, entry := range seed.Packs {
|
|
state.Packs = append(state.Packs, domain.LangPackSeedCatalogEntry{
|
|
LangPack: entry.Pack.LangPack,
|
|
LangCode: entry.Pack.LangCode,
|
|
Version: entry.Pack.Version,
|
|
SourceHash: entry.SourceHash,
|
|
ContentHash: entry.ContentHash,
|
|
StringsCount: entry.StringsCount,
|
|
})
|
|
}
|
|
return state
|
|
}
|
|
|
|
func invalidateLangPackSeedCatalogs(ctx context.Context, db sqlcgen.DBTX) error {
|
|
if _, err := db.Exec(ctx, `DELETE FROM seed_states WHERE key LIKE 'langpack:v2:catalog:%'`); err != nil {
|
|
return fmt.Errorf("invalidate langpack seed catalogs: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *LangPackStore) meta(ctx context.Context, langPack, langCode string) (domain.LangPack, bool, error) {
|
|
row, err := s.q.GetLangPackMeta(ctx, sqlcgen.GetLangPackMetaParams{
|
|
LangPack: langPack,
|
|
LangCode: langCode,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.LangPack{LangPack: langPack, LangCode: langCode}, false, nil
|
|
}
|
|
return domain.LangPack{}, false, fmt.Errorf("get lang pack meta: %w", err)
|
|
}
|
|
return domain.LangPack{
|
|
LangPack: row.LangPack,
|
|
LangCode: row.LangCode,
|
|
Version: int(row.Version),
|
|
}, true, nil
|
|
}
|
|
|
|
func langPackStringFromListRow(row sqlcgen.ListLangPackStringsRow) domain.LangPackString {
|
|
return domain.LangPackString{
|
|
Key: row.Key,
|
|
Value: row.Value,
|
|
Pluralized: row.Pluralized,
|
|
ZeroValue: row.ZeroValue,
|
|
OneValue: row.OneValue,
|
|
TwoValue: row.TwoValue,
|
|
FewValue: row.FewValue,
|
|
ManyValue: row.ManyValue,
|
|
OtherValue: row.OtherValue,
|
|
Deleted: row.Deleted,
|
|
}
|
|
}
|
|
|
|
func langPackStringFromKeysRow(row sqlcgen.GetLangPackStringsByKeysRow) domain.LangPackString {
|
|
return domain.LangPackString{
|
|
Key: row.Key,
|
|
Value: row.Value,
|
|
Pluralized: row.Pluralized,
|
|
ZeroValue: row.ZeroValue,
|
|
OneValue: row.OneValue,
|
|
TwoValue: row.TwoValue,
|
|
FewValue: row.FewValue,
|
|
ManyValue: row.ManyValue,
|
|
OtherValue: row.OtherValue,
|
|
Deleted: row.Deleted,
|
|
}
|
|
}
|