多租户更改

This commit is contained in:
richjohnrambo
2026-08-11 15:59:57 +08:00
parent 6c800de67d
commit c2076721fc
453 changed files with 102349 additions and 47 deletions
+295
View File
@@ -0,0 +1,295 @@
// Package adapter contains the interfaces to be implemented by the database adapter
package adapter
import (
"encoding/json"
"time"
"github.com/tinode/chat/server/auth"
t "github.com/tinode/chat/server/store/types"
)
// Adapter is the interface that must be implemented by a database
// adapter. The current schema supports a single connection by database type.
type Adapter interface {
// General
// Open and configure the adapter
Open(config json.RawMessage) error
// Close the adapter
Close() error
// IsOpen checks if the adapter is ready for use
IsOpen() bool
// GetDbVersion returns current database version.
GetDbVersion() (int, error)
// CheckDbVersion checks if the actual database version matches adapter version.
CheckDbVersion() error
// GetName returns the name of the adapter
GetName() string
// SetMaxResults configures how many results can be returned in a single DB call.
SetMaxResults(val int) error
// CreateDb creates the database optionally dropping an existing database first.
CreateDb(reset bool) error
// UpgradeDb upgrades database to the current adapter version.
UpgradeDb() error
// Version returns adapter version
Version() int
// DB connection stats object.
Stats() any
// User management
// UserCreate creates user record
UserCreate(user *t.User) error
// UserGet returns record for a given user ID
UserGet(uid t.Uid) (*t.User, error)
// UserGetAll returns user records for a given list of user IDs
UserGetAll(ids ...t.Uid) ([]t.User, error)
// UserDelete deletes user record
UserDelete(uid t.Uid, hard bool) error
// UserUpdate updates user record
UserUpdate(uid t.Uid, update map[string]any) error
// UserUpdateTags adds, removes, or resets user's tags
UserUpdateTags(uid t.Uid, add, remove, reset []string) ([]string, error)
// UserGetByCred returns user ID for the given validated credential.
UserGetByCred(method, value string) (t.Uid, error)
// UserUnreadCount returns the total number of unread messages in all topics with
// the R permission. If read fails, the counts are still returned with the original
// user IDs but with the unread count undefined and non-nil error.
UserUnreadCount(ids ...t.Uid) (map[t.Uid]int, error)
// UserGetUnvalidated returns a list of no more than 'limit' uids who never logged in,
// have no validated credentials and which haven't been updated since 'lastUpdatedBefore'.
UserGetUnvalidated(lastUpdatedBefore time.Time, limit int) ([]t.Uid, error)
// Credential management
// CredUpsert adds or updates a credential record. Returns true if record was inserted, false if updated.
CredUpsert(cred *t.Credential) (bool, error)
// CredGetActive returns the currently active credential record for the given method.
CredGetActive(uid t.Uid, method string) (*t.Credential, error)
// CredGetAll returns credential records for the given user and method, validated only or all.
CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error)
// CredDel deletes credentials for the given method/value. If method is empty, deletes all
// user's credentials.
CredDel(uid t.Uid, method, value string) error
// CredConfirm marks given credential as validated.
CredConfirm(uid t.Uid, method string) error
// CredFail increments count of failed validation attepmts for the given credentials.
CredFail(uid t.Uid, method string) error
// Authentication management for the basic authentication scheme
// AuthGetUniqueRecord returns authentication record for a given unique value i.e. login.
AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte, time.Time, error)
// AuthGetRecord returns authentication record given user ID and method.
AuthGetRecord(user t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)
// AuthAddRecord creates new authentication record
AuthAddRecord(user t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error
// AuthDelScheme deletes an existing authentication scheme for the user.
AuthDelScheme(user t.Uid, scheme string) error
// AuthDelAllRecords deletes all records of a given user.
AuthDelAllRecords(uid t.Uid) (int, error)
// AuthUpdRecord modifies an authentication record. Only non-default/non-zero values are updated.
AuthUpdRecord(user t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error
// Topic management
// TopicCreate creates a topic
TopicCreate(topic *t.Topic) error
// TopicCreateP2P creates a p2p topic
TopicCreateP2P(initiator, invited *t.Subscription) error
// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)
TopicGet(topic string) (*t.Topic, error)
// TopicsForUser loads subscriptions for a given user. Reads public value.
// When the 'opts.IfModifiedSince' query is not nil the subscriptions with UpdatedAt > opts.IfModifiedSince
// are returned, where UpdatedAt can be either a subscription, a topic, or a user update timestamp.
// This is need in order to support paginagion of subscriptions: get subscriptions page by page
// from the oldest updates to most recent:
// 1. Client already has subscriptions with the latest update timestamp X.
// 2. Client asks for N updated subscriptions since X. The server returns N with updates between X and Y.
// 3. Client goes to step 1 with X := Y.
TopicsForUser(uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error)
// UsersForTopic loads users' subscriptions for a given topic. Public is loaded.
UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error)
// OwnTopics loads a slice of topic names where the user is the owner.
OwnTopics(uid t.Uid) ([]string, error)
// ChannelsForUser loads a slice of topic names where the user is a channel reader and notifications (P) are enabled.
ChannelsForUser(uid t.Uid) ([]string, error)
// TopicShare creates topic subscriptions.
TopicShare(topic string, subs []*t.Subscription) error
// TopicDelete deletes topic, subscriptions, messages.
TopicDelete(topic string, isChan, hard bool) error
// TopicUpdateOnMessage increments Topic's or User's SeqId value and updates TouchedAt timestamp.
TopicUpdateOnMessage(topic string, msg *t.Message) error
// TopicUpdateSubCnt refreshes denormalized topic subscriber count.
TopicUpdateSubCnt(topic string) error
// TopicUpdate updates topic record.
TopicUpdate(topic string, update map[string]any) error
// TopicOwnerChange updates topic's owner
TopicOwnerChange(topic string, newOwner t.Uid) error
// Topic subscriptions
// SubscriptionGet reads a subscription of a user to a topic
SubscriptionGet(topic string, user t.Uid, keepDeleted bool) (*t.Subscription, error)
// SubsForUser loads all subscriptions of a given user. Does NOT load Public or Private values,
// does not load deleted subscriptions.
SubsForUser(user t.Uid) ([]t.Subscription, error)
// SubsForTopic gets a list of subscriptions to a given topic.. Does NOT load Public value.
SubsForTopic(topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error)
// SubsUpdate updates pasrt of a subscription object. Pass nil for fields which don't need to be updated
SubsUpdate(topic string, user t.Uid, update map[string]any) error
// SubsDelete deletes a single subscription
SubsDelete(topic string, user t.Uid) error
// Search
// Find searches for users or topics given a list of tags.
// - caller is the user or topic who is doing the searching, it will be skipped from results.
// - prefix if present will cause match rank highest in the results.
// - req is a list of required tag sets. Each set is a list of tags. The search will return
// all users/topics which have at least one tag from each set.
// - opt is a list of optional tags; if present the result will rank higher.
// - activeOnly if true will return only active subscriptions.
Find(caller, prefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error)
// FindOne returns topic or user which matches the given tag.
FindOne(tag string) (string, error)
// Messages
// MessageSave saves message to database
MessageSave(msg *t.Message) error
// MessageGetAll returns messages matching the query
MessageGetAll(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.Message, error)
// MessageDeleteList marks messages as deleted.
// Soft- or Hard- is defined by forUser value: forUser.IsZero == true is hard.
MessageDeleteList(topic string, toDel *t.DelMessage) error
// MessageGetDeleted returns a list of deleted message Ids.
MessageGetDeleted(topic string, forUser t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error)
// Devices (for push notifications)
// DeviceUpsert creates or updates a device record
DeviceUpsert(uid t.Uid, dev *t.DeviceDef) error
// DeviceGetAll returns all devices for a given set of users
DeviceGetAll(uid ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error)
// DeviceDelete deletes a device record
DeviceDelete(uid t.Uid, deviceID string) error
// File upload records. The files are stored outside of the database.
// FileStartUpload initializes a file upload.
FileStartUpload(fd *t.FileDef) error
// FileFinishUpload marks file upload as completed, successfully or otherwise.
FileFinishUpload(fd *t.FileDef, success bool, size int64) (*t.FileDef, error)
// FileGet fetches a record of a specific file
FileGet(fid string) (*t.FileDef, error)
// FileDeleteUnused deletes records where UseCount is zero. If olderThan is non-zero, deletes
// unused records with UpdatedAt before olderThan.
// Returns array of FileDef.Location of deleted filerecords so actual files can be deleted too.
FileDeleteUnused(olderThan time.Time, limit int) ([]string, error)
// FileLinkAttachments connects given topic or message to the file record IDs from the list.
FileLinkAttachments(topic string, userId, msgId t.Uid, fids []string) error
// Persistent cache management.
// PCacheGet reads a persistent cache entry.
PCacheGet(key string) (string, error)
// PCacheUpsert creates or updates a persistent cache entry.
PCacheUpsert(key string, value string, failOnDuplicate bool) error
// PCacheDelete deletes a single persistent cache entry.
PCacheDelete(key string) error
// PCacheExpire expires older entries with the specified key prefix.
PCacheExpire(keyPrefix string, olderThan time.Time) error
// Testing
// GetTestDB returns a currently open database connection.
GetTestDB() any
}
// TenantAdapter is an optional capability implemented by tenant-aware database adapters.
// Keeping it separate lets adapters be migrated independently while tenant-aware entry
// points fail closed when the configured adapter does not support tenant resolution.
type TenantAdapter interface {
TenantGetByCode(code string) (*t.Tenant, error)
}
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
// It is separate from Adapter while the remaining persistence domains are migrated.
type TenantAuthAdapter interface {
TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) (t.Uid, auth.Level, []byte, time.Time, error)
TenantAuthGetRecord(tenantID t.TenantID, user t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)
TenantAuthAddRecord(tenantID t.TenantID, user t.Uid, scheme, unique string, authLvl auth.Level,
secret []byte, expires time.Time) error
TenantAuthDelScheme(tenantID t.TenantID, user t.Uid, scheme string) error
TenantAuthUpdRecord(tenantID t.TenantID, user t.Uid, scheme, unique string, authLvl auth.Level,
secret []byte, expires time.Time) error
TenantUserGetByCred(tenantID t.TenantID, method, value string) (t.Uid, error)
TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (bool, error)
TenantCredGetActive(tenantID t.TenantID, uid t.Uid, method string) (*t.Credential, error)
TenantCredGetAll(tenantID t.TenantID, uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error)
TenantCredDel(tenantID t.TenantID, uid t.Uid, method, value string) error
TenantCredConfirm(tenantID t.TenantID, uid t.Uid, method string) error
TenantCredFail(tenantID t.TenantID, uid t.Uid, method string) error
}
// TenantBusinessAdapter is the mandatory tenant-aware persistence capability
// used by request handling code. Adapters which do not implement it fail closed
// at the Store boundary instead of falling back to legacy unscoped queries.
type TenantBusinessAdapter interface {
// TenantBusinessReady reports whether every method below enforces tenant
// isolation in its database queries. Startup is rejected while false.
TenantBusinessReady() bool
TenantUserCreate(t.TenantID, *t.User) error
TenantUserGet(t.TenantID, t.Uid) (*t.User, error)
TenantUserGetAll(t.TenantID, ...t.Uid) ([]t.User, error)
TenantUserDelete(t.TenantID, t.Uid, bool) error
TenantUserUpdate(t.TenantID, t.Uid, map[string]any) error
TenantUserUpdateTags(t.TenantID, t.Uid, []string, []string, []string) ([]string, error)
TenantUserUnreadCount(t.TenantID, ...t.Uid) (map[t.Uid]int, error)
TenantUserGetUnvalidated(t.TenantID, time.Time, int) ([]t.Uid, error)
TenantTopicCreate(t.TenantID, *t.Topic) error
TenantTopicCreateP2P(t.TenantID, *t.Subscription, *t.Subscription) error
TenantTopicGet(t.TenantID, string) (*t.Topic, error)
TenantTopicsForUser(t.TenantID, t.Uid, bool, *t.QueryOpt) ([]t.Subscription, error)
TenantUsersForTopic(t.TenantID, string, bool, *t.QueryOpt) ([]t.Subscription, error)
TenantOwnTopics(t.TenantID, t.Uid) ([]string, error)
TenantChannelsForUser(t.TenantID, t.Uid) ([]string, error)
TenantTopicShare(t.TenantID, string, []*t.Subscription) error
TenantTopicDelete(t.TenantID, string, bool, bool) error
TenantTopicUpdateOnMessage(t.TenantID, string, *t.Message) error
TenantTopicUpdateSubCnt(t.TenantID, string) error
TenantTopicUpdate(t.TenantID, string, map[string]any) error
TenantTopicOwnerChange(t.TenantID, string, t.Uid) error
TenantSubscriptionGet(t.TenantID, string, t.Uid, bool) (*t.Subscription, error)
TenantSubsForUser(t.TenantID, t.Uid) ([]t.Subscription, error)
TenantSubsForTopic(t.TenantID, string, bool, *t.QueryOpt) ([]t.Subscription, error)
TenantSubsUpdate(t.TenantID, string, t.Uid, map[string]any) error
TenantSubsDelete(t.TenantID, string, t.Uid) error
TenantFind(t.TenantID, string, string, [][]string, []string, bool) ([]t.Subscription, error)
TenantFindOne(t.TenantID, string) (string, error)
TenantMessageSave(t.TenantID, *t.Message) error
TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
TenantDeviceUpsert(t.TenantID, t.Uid, *t.DeviceDef) error
TenantDeviceGetAll(t.TenantID, ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error)
TenantDeviceDelete(t.TenantID, t.Uid, string) error
TenantFileStartUpload(t.TenantID, *t.FileDef) error
TenantFileFinishUpload(t.TenantID, *t.FileDef, bool, int64) (*t.FileDef, error)
TenantFileGet(t.TenantID, string) (*t.FileDef, error)
TenantFileDeleteUnused(t.TenantID, time.Time, int) ([]string, error)
TenantFileLinkAttachments(t.TenantID, string, t.Uid, t.Uid, []string) error
TenantPCacheGet(t.TenantID, string) (string, error)
TenantPCacheUpsert(t.TenantID, string, string, bool) error
TenantPCacheDelete(t.TenantID, string) error
TenantPCacheExpire(t.TenantID, string, time.Time) error
}
+185
View File
@@ -0,0 +1,185 @@
// Package common contains utility methods used by all adapters.
package common
import (
"encoding/json"
"sort"
"strconv"
"strings"
"time"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
type AuthRecord struct {
Unique string `json:"unique" bson:"_id"`
UserId string `json:"userid"`
Scheme string `json:"scheme"`
AuthLvl auth.Level `json:"authLvl"`
Secret []byte `json:"secret"`
Expires time.Time `json:"expires"`
}
// SelectEarliestUpdatedSubs selects no more than the given number of subscriptions from the
// given slice satisfying the query. When the number of subscriptions is greater than the limit,
// the subscriptions with the earliest timestamp are selected.
func SelectEarliestUpdatedSubs(subs []t.Subscription, opts *t.QueryOpt, maxResults int) []t.Subscription {
limit := maxResults
ims := time.Time{}
if opts != nil {
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
if opts.IfModifiedSince != nil {
ims = *opts.IfModifiedSince
}
}
// No cache management and the number of results is below the limit: return all.
if ims.IsZero() && len(subs) <= limit {
return subs
}
// Now that we fetched potentially more subscriptions than needed, we got to take those with the oldest modifications.
// Sorting in ascending order by modification time.
sort.Slice(subs, func(i, j int) bool {
return subs[i].LastModified().Before(subs[j].LastModified())
})
if !ims.IsZero() {
// Keep only those subscriptions which are newer than ims.
at := sort.Search(len(subs), func(i int) bool {
return subs[i].LastModified().After(ims)
})
subs = subs[at:]
}
// Trim slice at the limit.
if len(subs) > limit {
subs = subs[:limit]
}
return subs
}
// SelectLatestTime picks the latest update timestamp out of the two.
func SelectLatestTime(t1, t2 time.Time) time.Time {
if t1.Before(t2) {
// Subscription has not changed recently, use user's update timestamp.
return t2
}
return t1
}
// RangesToSql converts a slice of ranges to SQL BETWEEN or IN() constraint and arguments.
func RangesToSql(in []t.Range) (string, []any) {
if len(in) > 1 || in[0].Hi == 0 {
var args []any
for _, r := range in {
if r.Hi == 0 {
args = append(args, r.Low)
} else {
for i := r.Low; i < r.Hi; i++ {
args = append(args, i)
}
}
}
return "IN (?" + strings.Repeat(",?", len(args)-1) + ")", args
}
// Optimizing for a special case of single range low..hi.
// SQL's BETWEEN is inclusive-inclusive thus decrement Hi by 1.
return "BETWEEN ? AND ?", []any{in[0].Low, in[0].Hi - 1}
}
// DisjunctionSql converts a slice of disjunctions to SQL HAVING clause and arguments.
func DisjunctionSql(req [][]string, fieldName string) (string, []any) {
var args []any
counts := make([]string, 0, len(req))
for _, reqDisjunction := range req {
// At least one of the tags must be present.
if len(reqDisjunction) == 0 {
continue
}
counts = append(counts, "COUNT("+fieldName+" IN (?"+strings.Repeat(",?", len(reqDisjunction)-1)+") OR NULL)>=1")
for _, tag := range reqDisjunction {
args = append(args, tag)
}
}
return "HAVING " + strings.Join(counts, " AND ") + " ", args
}
// FilterFoundTags keeps only those tags in setTags that are present in the index.
func FilterFoundTags(setTags t.StringSlice, index map[string]struct{}) []string {
foundTags := make([]string, 0, 1)
for _, tag := range setTags {
if _, ok := index[tag]; ok {
foundTags = append(foundTags, tag)
}
}
return foundTags
}
// Convert to JSON before storing to JSON field.
func ToJSON(src any) []byte {
if src == nil {
return nil
}
jval, _ := json.Marshal(src)
return jval
}
// Deserialize JSON data from DB.
func FromJSON(src any) any {
if src == nil {
return nil
}
if bb, ok := src.([]byte); ok {
var out any
json.Unmarshal(bb, &out)
return out
}
return nil
}
// Convert update to a list of columns and arguments.
func UpdateByMap(update map[string]any) (cols []string, args []any) {
for col, arg := range update {
col = strings.ToLower(col)
if col == "public" || col == "trusted" || col == "private" || col == "aux" {
arg = ToJSON(arg)
}
cols = append(cols, col+"=?")
args = append(args, arg)
}
return
}
// If Tags field is updated, get the tags so tags table cab be updated too.
func ExtractTags(update map[string]any) []string {
var tags []string
if val := update["Tags"]; val != nil {
tags, _ = val.(t.StringSlice)
}
return []string(tags)
}
// EncodeUidString takes decoded string representation of int64, produce UID.
// UIDs are stored as decoded int64 values.
func EncodeUidString(str string) t.Uid {
unum, _ := strconv.ParseInt(str, 10, 64)
return store.EncodeUid(unum)
}
// DecodeUidString takes UID as string, converts it to int64 representation.
// UIDs are stored as decoded int64 values.
func DecodeUidString(str string) int64 {
uid := t.ParseUid(str)
return store.DecodeUid(uid)
}
+395
View File
@@ -0,0 +1,395 @@
package common
import (
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"github.com/tinode/chat/server/store/types"
)
func genTestData() []types.Subscription {
var testData = []types.Subscription{
{ObjHeader: types.ObjHeader{Id: "1", UpdatedAt: time.Date(2021, time.June, 1, 1, 11, 0, 0, time.Local)}},
{ObjHeader: types.ObjHeader{Id: "2", UpdatedAt: time.Date(2021, time.June, 2, 2, 12, 0, 0, time.Local)}},
{ObjHeader: types.ObjHeader{Id: "3", UpdatedAt: time.Date(2021, time.June, 3, 3, 13, 0, 0, time.Local)}},
{ObjHeader: types.ObjHeader{Id: "4", UpdatedAt: time.Date(2021, time.June, 4, 4, 14, 0, 0, time.Local)}},
{ObjHeader: types.ObjHeader{Id: "5", UpdatedAt: time.Date(2021, time.June, 5, 5, 15, 0, 0, time.Local)}},
{ObjHeader: types.ObjHeader{Id: "6", UpdatedAt: time.Date(2021, time.June, 6, 6, 16, 0, 0, time.Local)}},
{ObjHeader: types.ObjHeader{Id: "7", UpdatedAt: time.Date(2021, time.June, 7, 7, 17, 0, 0, time.Local)}},
{ObjHeader: types.ObjHeader{Id: "8", UpdatedAt: time.Date(2021, time.June, 8, 8, 18, 0, 0, time.Local)}},
{ObjHeader: types.ObjHeader{Id: "9", UpdatedAt: time.Date(2021, time.June, 9, 9, 19, 0, 0, time.Local)}},
{ObjHeader: types.ObjHeader{Id: "10", UpdatedAt: time.Date(2021, time.June, 10, 10, 20, 0, 0, time.Local)}},
}
// TouchedAt is either greater or equal to UpdatedAt.
testData[0].SetTouchedAt(time.Date(2021, time.June, 1, 1, 11, 0, 0, time.Local)) // 1
testData[1].SetTouchedAt(time.Date(2021, time.June, 4, 4, 12, 0, 0, time.Local)) // 3
testData[2].SetTouchedAt(time.Date(2021, time.June, 4, 2, 13, 0, 0, time.Local)) // 2
testData[3].SetTouchedAt(time.Date(2021, time.June, 4, 4, 14, 0, 0, time.Local)) // 4
testData[4].SetTouchedAt(time.Date(2021, time.June, 7, 5, 15, 0, 0, time.Local)) // 6
testData[5].SetTouchedAt(time.Date(2021, time.June, 6, 6, 16, 0, 0, time.Local)) // 5
testData[6].SetTouchedAt(time.Date(2021, time.June, 7, 7, 17, 0, 0, time.Local)) // 7
testData[7].SetTouchedAt(time.Date(2021, time.June, 9, 8, 18, 0, 0, time.Local)) // 8
testData[8].SetTouchedAt(time.Date(2021, time.June, 10, 11, 19, 0, 0, time.Local)) // 10
testData[9].SetTouchedAt(time.Date(2021, time.June, 10, 10, 20, 0, 0, time.Local)) // 9
return testData
}
func TestSelectEarliestUpdatedSubs(t *testing.T) {
getOrder := func(subs []types.Subscription) string {
var order []string
for i := range subs {
order = append(order, subs[i].Id)
}
return strings.Join(order, ",")
}
subs := SelectEarliestUpdatedSubs(genTestData(), nil, 100)
// No sorting when returning the full set.
expectedOrder := "1,2,3,4,5,6,7,8,9,10"
sortOrder := getOrder(subs)
if sortOrder != expectedOrder {
t.Error("Wrong results returned. Expected:", expectedOrder, "; Got:", sortOrder)
}
// Sorted, oldest 9 results.
subs = SelectEarliestUpdatedSubs(genTestData(), nil, 9)
expectedOrder = "1,3,2,4,6,5,7,8,10"
sortOrder = getOrder(subs)
if sortOrder != expectedOrder {
t.Error("Limited query returned wrong results. Expected:", expectedOrder, "; Got:", sortOrder)
}
// Sorted, oldest 9 results.
subs = SelectEarliestUpdatedSubs(genTestData(), &types.QueryOpt{Limit: 20}, 9)
expectedOrder = "1,3,2,4,6,5,7,8,10"
sortOrder = getOrder(subs)
if sortOrder != expectedOrder {
t.Error("Limited query (2) returned wrong results. Expected:", expectedOrder, "; Got:", sortOrder)
}
// Sorted, oldest 9 results.
subs = SelectEarliestUpdatedSubs(genTestData(), &types.QueryOpt{Limit: 9}, 20)
expectedOrder = "1,3,2,4,6,5,7,8,10"
sortOrder = getOrder(subs)
if sortOrder != expectedOrder {
t.Error("Limited query (3) returned wrong results. Expected:", expectedOrder, "; Got:", sortOrder)
}
ims := time.Date(2021, time.June, 7, 8, 16, 15, 0, time.Local)
subs = SelectEarliestUpdatedSubs(genTestData(), &types.QueryOpt{Limit: 6, IfModifiedSince: &ims}, 20)
expectedOrder = "8,10,9"
sortOrder = getOrder(subs)
if sortOrder != expectedOrder {
t.Error("Date & count limited query returned wrong results. Expected:", expectedOrder, "; Got:", sortOrder)
}
ims = time.Date(2021, time.June, 4, 4, 13, 15, 0, time.Local)
subs = SelectEarliestUpdatedSubs(genTestData(), &types.QueryOpt{Limit: 3, IfModifiedSince: &ims}, 20)
expectedOrder = "4,6,5"
sortOrder = getOrder(subs)
if sortOrder != expectedOrder {
t.Error("Count & date limited query returned wrong results. Expected:", expectedOrder, "; Got:", sortOrder)
}
}
func TestSelectLatestTime(t *testing.T) {
t1 := time.Date(2021, time.June, 1, 10, 0, 0, 0, time.UTC)
t2 := time.Date(2021, time.June, 2, 10, 0, 0, 0, time.UTC)
// t1 is before t2, should return t2
result := SelectLatestTime(t1, t2)
if !result.Equal(t2) {
t.Errorf("Expected %v, got %v", t2, result)
}
// t2 is after t1, should return t2
result = SelectLatestTime(t2, t1)
if !result.Equal(t2) {
t.Errorf("Expected %v, got %v", t2, result)
}
// Equal times, should return either one (in this case t1)
result = SelectLatestTime(t1, t1)
if !result.Equal(t1) {
t.Errorf("Expected %v, got %v", t1, result)
}
}
func TestRangesToSql(t *testing.T) {
// Test single range with Hi = 0 (IN clause)
ranges := []types.Range{{Low: 5, Hi: 0}}
sql, args := RangesToSql(ranges)
expectedSql := "IN (?)"
expectedArgs := []any{5}
if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
}
if !reflect.DeepEqual(args, expectedArgs) {
t.Errorf("Expected args %v, got %v", expectedArgs, args)
}
// Test single range with Hi > 0 (BETWEEN clause)
ranges = []types.Range{{Low: 5, Hi: 8}}
sql, args = RangesToSql(ranges)
expectedSql = "BETWEEN ? AND ?"
expectedArgs = []any{5, 7} // Hi-1 for BETWEEN
if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
}
if !reflect.DeepEqual(args, expectedArgs) {
t.Errorf("Expected args %v, got %v", expectedArgs, args)
}
// Test multiple ranges (IN clause)
ranges = []types.Range{{Low: 1, Hi: 3}, {Low: 5, Hi: 0}, {Low: 8, Hi: 10}}
sql, args = RangesToSql(ranges)
expectedSql = "IN (?,?,?,?,?)"
expectedArgs = []any{1, 2, 5, 8, 9}
if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
}
if !reflect.DeepEqual(args, expectedArgs) {
t.Errorf("Expected args %v, got %v", expectedArgs, args)
}
}
func TestDisjunctionSql(t *testing.T) {
// Test single disjunction
req := [][]string{{"tag1", "tag2", "tag3"}}
sql, args := DisjunctionSql(req, "tagname")
expectedSql := "HAVING COUNT(tagname IN (?,?,?) OR NULL)>=1 "
expectedArgs := []any{"tag1", "tag2", "tag3"}
if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
}
if !reflect.DeepEqual(args, expectedArgs) {
t.Errorf("Expected args %v, got %v", expectedArgs, args)
}
// Test multiple disjunctions
req = [][]string{{"tag1", "tag2"}, {"tag3"}, {"tag4", "tag5"}}
sql, args = DisjunctionSql(req, "fieldname")
expectedSql = "HAVING COUNT(fieldname IN (?,?) OR NULL)>=1 AND COUNT(fieldname IN (?) OR NULL)>=1 AND COUNT(fieldname IN (?,?) OR NULL)>=1 "
expectedArgs = []any{"tag1", "tag2", "tag3", "tag4", "tag5"}
if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
}
if !reflect.DeepEqual(args, expectedArgs) {
t.Errorf("Expected args %v, got %v", expectedArgs, args)
}
// Test empty disjunctions (should be skipped)
req = [][]string{{"tag1"}, {}, {"tag2"}}
sql, args = DisjunctionSql(req, "fieldname")
expectedSql = "HAVING COUNT(fieldname IN (?) OR NULL)>=1 AND COUNT(fieldname IN (?) OR NULL)>=1 "
expectedArgs = []any{"tag1", "tag2"}
if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
}
if !reflect.DeepEqual(args, expectedArgs) {
t.Errorf("Expected args %v, got %v", expectedArgs, args)
}
}
func TestFilterFoundTags(t *testing.T) {
setTags := types.StringSlice{"tag1", "tag2", "tag3", "tag4", "tag5"}
index := map[string]struct{}{
"tag1": {},
"tag3": {},
"tag5": {},
"tag6": {}, // Not in setTags
}
result := FilterFoundTags(setTags, index)
expected := []string{"tag1", "tag3", "tag5"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with empty index
emptyIndex := map[string]struct{}{}
result = FilterFoundTags(setTags, emptyIndex)
expected = []string{}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with empty setTags
emptyTags := types.StringSlice{}
result = FilterFoundTags(emptyTags, index)
expected = []string{}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
}
func TestToJSON(t *testing.T) {
// Test with nil
result := ToJSON(nil)
if result != nil {
t.Errorf("Expected nil, got %v", result)
}
// Test with string
input := "test string"
result = ToJSON(input)
expected := []byte(`"test string"`)
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Test with map
input2 := map[string]any{"key": "value", "number": 42}
result = ToJSON(input2)
// Parse back to verify
var parsed map[string]any
if err := json.Unmarshal(result, &parsed); err != nil {
t.Errorf("Failed to unmarshal result: %v", err)
}
if parsed["key"] != "value" || parsed["number"] != float64(42) {
t.Errorf("JSON conversion failed, got %v", parsed)
}
}
func TestFromJSON(t *testing.T) {
// Test with nil
result := FromJSON(nil)
if result != nil {
t.Errorf("Expected nil, got %v", result)
}
// Test with valid JSON bytes
input := []byte(`{"key": "value", "number": 42}`)
result = FromJSON(input)
if resultMap, ok := result.(map[string]any); ok {
if resultMap["key"] != "value" || resultMap["number"] != float64(42) {
t.Errorf("JSON deserialization failed, got %v", resultMap)
}
} else {
t.Errorf("Expected map[string]any, got %T", result)
}
// Test with invalid JSON bytes
invalidInput := []byte(`{invalid json}`)
result = FromJSON(invalidInput)
if result != nil {
t.Errorf("Expected nil for invalid JSON, got %v", result)
}
// Test with non-byte slice
stringInput := "not bytes"
result = FromJSON(stringInput)
if result != nil {
t.Errorf("Expected nil for non-byte input, got %v", result)
}
}
func TestUpdateByMap(t *testing.T) {
update := map[string]any{
"Name": "John Doe",
"Age": 30,
"Public": map[string]string{"avatar": "url"},
"Private": map[string]string{"email": "john@example.com"},
"Trusted": map[string]bool{"verified": true},
"UpdatedAt": time.Now(),
}
cols, args := UpdateByMap(update)
// Check that we have the right number of columns and args
if len(cols) != len(args) || len(cols) != len(update) {
t.Errorf("Expected %d columns and args, got %d cols and %d args", len(update), len(cols), len(args))
}
// Verify column format
for _, col := range cols {
if !strings.Contains(col, "=?") {
t.Errorf("Column should contain '=?', got %s", col)
}
}
// Check that JSON fields are properly handled
foundPublic := false
foundPrivate := false
foundTrusted := false
for i, col := range cols {
if strings.HasPrefix(col, "public=?") {
foundPublic = true
// Should be JSON bytes
if _, ok := args[i].([]byte); !ok {
t.Errorf("Public field should be []byte, got %T", args[i])
}
}
if strings.HasPrefix(col, "private=?") {
foundPrivate = true
if _, ok := args[i].([]byte); !ok {
t.Errorf("Private field should be []byte, got %T", args[i])
}
}
if strings.HasPrefix(col, "trusted=?") {
foundTrusted = true
if _, ok := args[i].([]byte); !ok {
t.Errorf("Trusted field should be []byte, got %T", args[i])
}
}
}
if !foundPublic || !foundPrivate || !foundTrusted {
t.Error("Missing JSON fields in output")
}
}
func TestExtractTags(t *testing.T) {
// Test with Tags field present
update := map[string]any{
"Name": "John",
"Tags": types.StringSlice{"tag1", "tag2", "tag3"},
"Age": 30,
}
tags := ExtractTags(update)
expected := []string{"tag1", "tag2", "tag3"}
if !reflect.DeepEqual(tags, expected) {
t.Errorf("Expected %v, got %v", expected, tags)
}
// Test with no Tags field
update = map[string]any{
"Name": "John",
"Age": 30,
}
tags = ExtractTags(update)
expected = nil
if !reflect.DeepEqual(tags, expected) {
t.Errorf("Expected %+v, got %+v", expected, tags)
}
// Test with nil Tags field
update = map[string]any{
"Name": "John",
"Tags": nil,
"Age": 30,
}
tags = ExtractTags(update)
expected = nil
if !reflect.DeepEqual(tags, expected) {
t.Errorf("Expected %+v, got %+v", expected, tags)
}
// Test with wrong type for Tags field
update = map[string]any{
"Name": "John",
"Tags": "not a slice",
"Age": 30,
}
tags = ExtractTags(update)
expected = nil
if !reflect.DeepEqual(tags, expected) {
t.Errorf("Expected %+v, got %+v", expected, tags)
}
}
+382
View File
@@ -0,0 +1,382 @@
package test_data
import (
"time"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/db/common"
"github.com/tinode/chat/server/store/types"
)
type TestData struct {
UGen *types.UidGenerator
Users []*types.User
Creds []*types.Credential
Recs []common.AuthRecord
Topics []*types.Topic
Subs []*types.Subscription
Msgs []*types.Message
Devs []*types.DeviceDef
Files []*types.FileDef
// Tags: add, remove, reset
Tags [][]string
Now time.Time
}
func initUsers(now time.Time) []*types.User {
users := make([]*types.User, 0, 3)
users = append(users, &types.User{
ObjHeader: types.ObjHeader{
Id: "3ysxkod5hNM",
},
UserAgent: "SomeAgent v1.2.3",
Tags: []string{"alice"},
})
users = append(users, &types.User{
ObjHeader: types.ObjHeader{
Id: "9AVDamaNCRY",
},
UserAgent: "Tinode Web v111.222.333",
Tags: []string{"bob"},
})
users = append(users, &types.User{
ObjHeader: types.ObjHeader{
Id: "0QLrX3WPS2o",
},
UserAgent: "Tindroid v1.2.3",
Tags: []string{"carol"},
})
for _, user := range users {
// Initialize timestamps.
user.InitTimes()
// Assign user.id from user.Id.
user.Uid()
}
deletedAt := now.Add(10 * time.Minute)
users[2].State = types.StateDeleted
users[2].StateAt = &deletedAt
return users
}
func initCreds(now time.Time, users []*types.User) []*types.Credential {
creds := make([]*types.Credential, 0, 6)
creds = append(creds, &types.Credential{ // 0
User: users[0].Id,
Method: "email",
Value: "alice@test.example.com",
Done: true,
})
creds = append(creds, &types.Credential{ // 1
User: users[1].Id,
Method: "email",
Value: "bob@test.example.com",
Done: true,
})
creds = append(creds, &types.Credential{ // 2
User: users[1].Id,
Method: "email",
Value: "bob@test.example.com",
})
creds = append(creds, &types.Credential{ // 3
User: users[2].Id,
Method: "tel",
Value: "+998991112233",
})
creds = append(creds, &types.Credential{ // 4
User: users[2].Id,
Method: "tel",
Value: "+998993332211",
Done: true,
})
creds = append(creds, &types.Credential{ // 5
User: users[2].Id,
Method: "email",
Value: "asdf@example.com",
})
for _, cred := range creds {
cred.InitTimes()
}
creds[3].CreatedAt = now.Add(-10 * time.Minute)
creds[3].UpdatedAt = now.Add(-10 * time.Minute)
return creds
}
func initAuthRecords(now time.Time, users []*types.User) []common.AuthRecord {
recs := make([]common.AuthRecord, 0, 2)
recs = append(recs, common.AuthRecord{
Unique: "basic:alice",
UserId: users[0].Id,
Scheme: "basic",
AuthLvl: auth.LevelAuth,
Secret: []byte{'a', 'l', 'i', 'c', 'e'},
Expires: now.Add(24 * time.Hour),
})
recs = append(recs, common.AuthRecord{
Unique: "basic:bob",
UserId: users[1].Id,
Scheme: "basic",
AuthLvl: auth.LevelAuth,
Secret: []byte{'b', 'o', 'b'},
Expires: now.Add(24 * time.Hour),
})
return recs
}
func initTopics(now time.Time, users []*types.User) []*types.Topic {
topics := make([]*types.Topic, 0, 5)
topics = append(topics, &types.Topic{
ObjHeader: types.ObjHeader{
Id: "grpgRXf0rU4uR4",
CreatedAt: now.Add(10 * time.Minute),
UpdatedAt: now,
},
TouchedAt: now,
Owner: users[0].Id,
SeqId: 111,
Tags: []string{"travel", "zxcv"},
SubCnt: 2,
})
topics = append(topics, &types.Topic{
ObjHeader: types.ObjHeader{
Id: "p2p9AVDamaNCRbfKzGSh3mE0w",
CreatedAt: now,
UpdatedAt: now,
},
TouchedAt: now,
SeqId: 12,
})
topics = append(topics, &types.Topic{
ObjHeader: types.ObjHeader{
Id: "p2pxQLrX3WPS2rfKzGSh3mE0w",
CreatedAt: now,
UpdatedAt: now,
},
TouchedAt: now,
SeqId: 15,
})
topics = append(topics, &types.Topic{
ObjHeader: types.ObjHeader{
Id: "p2pE1iE7I9JN5ESv44HiLbj1A",
CreatedAt: now,
UpdatedAt: now,
},
TouchedAt: now,
SeqId: 555,
})
topics = append(topics, &types.Topic{
ObjHeader: types.ObjHeader{
Id: "p2pQvr1xwKU01LfKzGSh3mE0w",
CreatedAt: now,
UpdatedAt: now,
},
TouchedAt: now,
SeqId: 333,
})
return topics
}
func initSubs(now time.Time, users []*types.User, topics []*types.Topic) []*types.Subscription {
subs := make([]*types.Subscription, 0, 6)
subs = append(subs, &types.Subscription{
ObjHeader: types.ObjHeader{
CreatedAt: now,
UpdatedAt: now.Add(10 * time.Minute),
},
User: users[0].Id,
Topic: topics[0].Id,
RecvSeqId: 5,
ReadSeqId: 1,
ModeWant: 255,
ModeGiven: 255,
})
subs = append(subs, &types.Subscription{
ObjHeader: types.ObjHeader{
CreatedAt: now,
UpdatedAt: now.Add(15 * time.Minute),
},
User: users[1].Id,
Topic: topics[0].Id,
RecvSeqId: 6,
ReadSeqId: 3,
ModeWant: 47,
ModeGiven: 47,
})
subs = append(subs, &types.Subscription{
ObjHeader: types.ObjHeader{
CreatedAt: now.Add(-10 * time.Hour),
UpdatedAt: now.Add(-10 * time.Hour),
},
User: users[0].Id,
Topic: topics[1].Id,
RecvSeqId: 9,
ReadSeqId: 5,
ModeWant: 47,
ModeGiven: 47,
})
subs = append(subs, &types.Subscription{
ObjHeader: types.ObjHeader{
CreatedAt: now,
UpdatedAt: now.Add(20 * time.Minute),
},
User: users[1].Id,
Topic: topics[1].Id,
RecvSeqId: 9,
ReadSeqId: 5,
ModeWant: 47,
ModeGiven: 47,
})
subs = append(subs, &types.Subscription{
ObjHeader: types.ObjHeader{
CreatedAt: now,
UpdatedAt: now.Add(30 * time.Minute),
},
User: users[2].Id,
Topic: topics[2].Id,
RecvSeqId: 0,
ReadSeqId: 0,
ModeWant: 47,
ModeGiven: 47,
})
subs = append(subs, &types.Subscription{
ObjHeader: types.ObjHeader{
CreatedAt: now,
UpdatedAt: now.Add(40 * time.Minute),
},
User: users[2].Id,
Topic: topics[3].Id,
RecvSeqId: 555,
ReadSeqId: 455,
ModeWant: 47,
ModeGiven: 47,
})
for _, sub := range subs {
sub.SetTouchedAt(now)
}
return subs
}
func initMessages(users []*types.User, topics []*types.Topic) []*types.Message {
msgs := make([]*types.Message, 0, 6)
msgs = append(msgs, &types.Message{
SeqId: 1,
Topic: topics[0].Id,
From: users[0].Id,
Content: "msg1",
})
msgs = append(msgs, &types.Message{
SeqId: 2,
Topic: topics[0].Id,
From: users[2].Id,
Content: "msg2",
DeletedFor: []types.SoftDelete{{
User: users[0].Id,
DelId: 1}},
})
msgs = append(msgs, &types.Message{
SeqId: 3,
Topic: topics[0].Id,
From: users[0].Id,
Content: "msg31",
})
msgs = append(msgs, &types.Message{
SeqId: 1,
Topic: topics[1].Id,
From: users[1].Id,
Content: "msg1",
})
msgs = append(msgs, &types.Message{
SeqId: 5,
Topic: topics[1].Id,
From: users[1].Id,
Content: "msg2",
})
msgs = append(msgs, &types.Message{
SeqId: 11,
Topic: topics[1].Id,
From: users[0].Id,
Content: "msg3",
})
for i, msg := range msgs {
msg.InitTimes()
msg.SetUid(types.Uid(i + 1))
}
return msgs
}
func initDevices(now time.Time) []*types.DeviceDef {
devs := make([]*types.DeviceDef, 0, 2)
devs = append(devs, &types.DeviceDef{
DeviceId: "2934ujfoviwj09ntf094",
Platform: "Android",
LastSeen: now,
Lang: "en_EN",
})
devs = append(devs, &types.DeviceDef{
DeviceId: "pogpjb023b09gfdmp",
Platform: "iOS",
LastSeen: now,
Lang: "en_EN",
})
return devs
}
func initFileDefs(now time.Time, users []*types.User) []*types.FileDef {
files := make([]*types.FileDef, 0, 2)
files = append(files, &types.FileDef{
ObjHeader: types.ObjHeader{
CreatedAt: now,
UpdatedAt: now,
},
Status: types.UploadStarted,
User: users[0].Id,
MimeType: "application/pdf",
Location: "uploads/qwerty.pdf",
Size: 123456,
})
files = append(files, &types.FileDef{
ObjHeader: types.ObjHeader{
CreatedAt: now.Add(60 * time.Minute),
UpdatedAt: now.Add(60 * time.Minute),
},
Status: types.UploadStarted,
User: users[0].Id,
Location: "uploads/asdf.txt",
Size: 654321,
})
files[0].SetUid(types.Uid(1001))
files[1].SetUid(types.Uid(1002))
return files
}
func initTags() [][]string {
// Tags must be lowercase and non-repeating.
addTags := []string{"tag1", "alice"}
removeTags := []string{"alice", "tag1", "tag2"}
resetTags := []string{"alice", "tag111", "tag333"}
return [][]string{addTags, removeTags, resetTags}
}
func InitTestData() *TestData {
// Use fixed timestamp to make tests more predictable
var now = time.Date(2021, time.June, 12, 11, 39, 24, 15, time.Local).UTC().Round(time.Millisecond)
var uGen = &types.UidGenerator{}
if err := uGen.Init(11, []byte("testtesttesttest")); err != nil {
return nil
}
var users = initUsers(now)
var topics = initTopics(now, users)
return &TestData{
UGen: uGen,
Users: users,
Creds: initCreds(now, users),
Recs: initAuthRecords(now, users),
Topics: topics,
Subs: initSubs(now, users, topics),
Msgs: initMessages(users, topics),
Devs: initDevices(now),
Files: initFileDefs(now, users),
Tags: initTags(),
Now: now,
}
}
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
# MongoDB Database Schema
## Database `tinode`
### Table `users`
Stores user accounts
Fields:
* `_id` user id, primary key
* `createdat` timestamp when the user was created
* `updatedat` timestamp when user metadata was updated
* `access` user's default access level for peer-to-peer topics
* `auth`, `anon` default permissions for authenticated and anonymous users
* `public` application-defined data
* `state` account state: normal (ok), suspended, soft-deleted
* `stateat` timestamp when the state was last updated or NULL
* `lastseen` timestamp when the user was last online
* `useragent` client User-Agent used when last online
* `tags` unique strings for user discovery
* `devices` client devices for push notifications
* `deviceid` device registration ID
* `platform` device platform string (iOS, Android, Web)
* `lastseen` last logged in
* `lang` device language, ISO code
Indexes:
* `_id` primary key
* `tags` multikey-index (indexed array)
* `deletedat` index
* `deviceids` multikey-index of push notification tokens
Sample:
```json
{
"access": {
"anon": 0 ,
"auth": 47
} ,
"createdat": "2019-10-11T12:13:14.522Z" ,
"state": 0,
"stateat": null ,
"devices": null ,
"_id": "7yUCHniegrM" ,
"lastseen": "2019-10-11T12:13:14.522Z" ,
"public": {
"fn": "Alice Johnson" ,
"photo": {
"data": Binary('/9j/4AAQSkZJRgAB...'),
"type": "jpg"
}
} ,
"state": 1 ,
"tags": [
"email:alice@example.com" ,
"tel:17025550001"
] ,
"updatedat": "2019-10-11T12:13:14.522Z",
"useragent": "TinodeWeb/0.13 (MacIntel) tinodejs/0.13"
}
```
### Table `auth`
Stores authentication secrets
Fields:
* `_id` unique string which identifies this record, primary key; defined as "_authentication scheme_':'_some unique value per scheme_"
* `userid` ID of the user who owns the record
* `secret` shared secret, for instance bcrypt of password
* `authLvl` authentication level
* `expires` timestamp when the records expires
Indexes:
* `_id` primary key
* `userid` index
Sample:
```json
{
"_id": "basic:alice" ,
"authLvl": 20 ,
"expires": "2019-10-11T12:13:14.522Z" ,
"secret": Binary('/9j/RgAB...'),
"userid": "7yUCHniegrM"
}
```
### Table `topics`
The table stores topics.
Fields:
* `_id` name of the topic, primary key
* `createdat` topic creation time
* `updatedat` timestamp of the last change to topic metadata
* `access` stores topic's default access permissions
* `auth`, `anon` permissions for authenticated and anonymous users respectively
* `owner` ID of the user who owns the topic
* `public` application-defined data
* `state` topic state: normal (ok), suspended, soft-deleted
* `stateat` timestamp when the state was last updated or NULL
* `seqid` sequential ID of the last message
* `delid` topic-sequential ID of the deletion operation
* `usebt` currently unused
Indexes:
* `_id` primary key
* `owner` index
* `tags` multikey index
Sample:
```json
{
"access": {
"anon": 64 ,
"auth": 64
} ,
"delid": 0,
"createdat": "2019-10-11T12:13:14.522Z",
"lastmessageat": "2019-10-11T12:13:14.522Z" ,
"id": "p2pavVGHLCBbKrvJQIeeJ6Csw" ,
"owner": "v2JyG4OLSoA" ,
"public": {
"fn": "Travel, travel, travel" ,
"photo": {
"data": Binary('/9j/RgAB...') ,
"type": "jpg"
}
} ,
"seqid": 14,
"state": 0,
"stateat": null,
"updatedat": "2019-10-11T12:13:14.522Z" ,
"usebt": false
}
```
### Table `subscriptions`
The table stores relationships between users and topics.
Fields:
* `_id` used for object retrieval
* `createdat` timestamp when the user was created
* `updatedat` timestamp when user metadata was updated
* `deletedat` currently unused
* `readseqid` id of the message last read by the user
* `recvseqid` id of the message last received by user device
* `delid` topic-sequential ID of the soft-deletion operation
* `topic` name of the topic subscribed to
* `user` subscriber's user ID
* `modewant` access mode that user wants when accessing the topic
* `modegiven` access mode granted to user by the topic
* `private` application-defined data, accessible by the user only
Indexes:
* `_id` primary key composed as "_topic name_':'_user ID_"
* `user` index
* `topic` index
Sample:
```json
{
"_id": "grpjajVKrHn0PU:v2JyG4OLSoA" ,
"createdat": "2019-10-11T12:13:14.522Z" ,
"updatedat": "2019-10-11T12:13:14.522Z" ,
"deletedat": null ,
"user": "v2JyG4OLSoA",
"topic": "grpjajVKrHn0PU" ,
"recvseqid": 0 ,
"readseqid": 0 ,
"modewant": 47 ,
"modegiven": 47 ,
"private": "Kirgudu" ,
"state": 0
}
```
### Table `messages`
The table stores `{data}` messages
Fields:
* `_id` currently unused, primary key
* `createdat` timestamp when the message was created
* `updatedat` initially equal to CreatedAt, for deleted messages equal to DeletedAt
* `deletedfor` array of user IDs which soft-deleted the message
* `delid` topic-sequential ID of the soft-deletion operation
* `user` ID of the user who soft-deleted the message
* `from` ID of the user who generated this message
* `topic` which received this message
* `seqid` messages ID - sequential number of the message in the topic
* `head` message headers
* `attachments` denormalized IDs of files attached to the message
* `content` application-defined message payload
Indexes:
* `_id` primary key
Sample:
```json
{
"_id": "LLXKEe9W4Bs" ,
"createdat": "2019-10-11T12:13:14.522Z" ,
"updatedat": "2019-10-11T12:13:14.522Z",
"deletedfor": [
{
"delid": 1 ,
"user": "wTI0jO9rEqY"
}
] ,
"seqid": 3 ,
"topic": "p2pJhbJnya8z5PBMjSM72sSpg",
"from": "wTI0jO9rEqY" ,
"head": {
"mime": "text/x-drafty"
} ,
"content": {
"fmt": [
{
"len": 6 ,
"tp": "ST"
}
] ,
"txt": "Hello!"
}
}
```
### Table `dellog`
The table stores records of message deletions
Fields:
* `_id` currently unused, primary key
* `createdat` timestamp when the record was created
* `updatedat` timestamp equal to CreatedAt
* `delid` topic-sequential ID of the deletion operation.
* `deletedfor` ID of the user for soft-deletions, blank string for hard-deletions
* `topic` affected topic
* `seqidranges` array of ranges of deleted message IDs (see `messages.seqid`)
Indexes:
* `_id` primary key
* `topic_delid` compound index `["Topic", "DelId"]`
Sample:
```json
{
"_id": "9LfrjW349Rc",
"createdat": "2019-10-11T12:13:14.522Z",
"updatedat": "2019-10-11T12:13:14.522Z",
"topic": "grpGx7fpjQwVC0",
"delid": 18,
"deletedfor": "xY-YHx09-WI",
"seqidranges": [
{
"low": 20,
"hi": 25
}
]
}
```
### Table `credentials`
The tables stores user credentials used for validation.
* `_id` credential, primary key
* `createdat` timestamp when the record was created
* `updatedat` timestamp when the last validation attempt was performed (successful or not).
* `method` validation method
* `done` indicator if the credential is validated
* `resp` expected validation response
* `retries` number of failed attempts at validation
* `user` id of the user who owns this credential
* `value` value of the credential
Indexes:
* `_id` Primary key composed either as `user`:`method`:`value` for unconfirmed credentials or as `method`:`value` for confirmed.
* `user` Index
Sample:
```json
{
"Id": "tel:+17025550001",
"CreatedAt": "2019-10-11T12:13:14.522Z",
"UpdatedAt": "2019-10-11T12:13:14.522Z",
"Method": "tel" ,
"Done": true ,
"Resp": "123456" ,
"Retries": 0 ,
"User": "k3srBRk9RYw" ,
"Value": "+17025550001"
}
```
### Table `fileuploads`
The table stores records of uploaded files. The files themselves are stored outside of the database.
* `_id` unique user-visible file name, primary key
* `createdat` timestamp when the record was created
* `updatedat` timestamp of when th upload has cmpleted or failed
* `user` id of the user who uploaded this file.
* `location` actual location of the file on the server.
* `mimetype` file content type as a [Mime](https://en.wikipedia.org/wiki/MIME) string.
* `size` size of the file in bytes. Could be 0 if upload has not completed yet.
* `usecount` count of messages referencing this file.
* `status` upload status: 0 pending, 1 completed, -1 failed.
Indexes:
* `_id` file name, primary key
* `user` index
* `usecount` index
Sample:
```json
{
"_id": "sFmjlQ_kA6A" ,
"createdat": "2019-10-11T12:13:14.522Z" ,
"updatedat": "2019-10-11T12:13:14.522Z" ,
"location": "uploads/sFmjlQ_kA6A" ,
"mimetype": "image/jpeg" ,
"size": 54961090 ,
"usecount": 3,
"status": 1 ,
"user": "7j-RR1V7O3Y"
}
```
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"reset_db_data": true,
"adapters": {
"mongodb": {
"database": "tinode_test",
//"replica_set": "rs0",
"addresses": "localhost:27017",
//"username": "tinode_test",
//"password": "tinode_test",
}
}
}
File diff suppressed because it is too large Load Diff
+271
View File
@@ -0,0 +1,271 @@
# THIS SCHEMA FILE IS FOR REFERENCE/DOCUMENTATION ONLY!
# DO NOT USE IT TO INITIALIZE THE DATABASE.
# Read installation instructions first.
# The following line will produce an intentional error.
'READ INSTALLATION INSTRUCTIONS!';
# The actual schema is below.
DROP DATABASE IF EXISTS tinode;
CREATE DATABASE tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE tinode;
CREATE TABLE im_tenant(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_code(code)
);
CREATE TABLE im_tenant_config(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id),
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
);
INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,1,0,0);
CREATE TABLE kvmeta(
`key` VARCHAR(64),
createdat DATETIME(3),
`value` TEXT,
PRIMARY KEY(`key`),
INDEX kvmeta_createdat_key(createdat, `key`)
);
INSERT INTO kvmeta(`key`, `value`) VALUES("version", "117");
CREATE TABLE users(
id BIGINT NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
state SMALLINT NOT NULL DEFAULT 0,
stateat DATETIME(3),
access JSON,
lastseen DATETIME,
useragent VARCHAR(255) DEFAULT '',
public JSON,
tags JSON, -- Denormalized array of tags
PRIMARY KEY(id),
INDEX users_state_stateat(state, stateat),
INDEX users_lastseen_updatedat(lastseen, updatedat)
);
# Indexed user tags.
CREATE TABLE usertags(
id INT NOT NULL AUTO_INCREMENT,
userid BIGINT NOT NULL,
tag VARCHAR(96) NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES users(id),
INDEX usertags_tag(tag),
UNIQUE INDEX usertags_userid_tag(userid, tag)
);
# Indexed devices. Normalized into a separate table.
CREATE TABLE devices(
id INT NOT NULL AUTO_INCREMENT,
userid BIGINT NOT NULL,
hash CHAR(16) NOT NULL,
deviceid TEXT NOT NULL,
platform VARCHAR(32),
lastseen DATETIME NOT NULL,
lang VARCHAR(8),
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES users(id),
UNIQUE INDEX devices_hash(hash)
);
# Authentication records for the basic authentication scheme.
CREATE TABLE auth(
id INT NOT NULL AUTO_INCREMENT,
uname VARCHAR(32) NOT NULL,
userid BIGINT NOT NULL,
scheme VARCHAR(16) NOT NULL,
authlvl SMALLINT NOT NULL,
secret VARCHAR(255) NOT NULL,
expires DATETIME,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES users(id),
UNIQUE INDEX auth_userid_scheme(userid, scheme),
UNIQUE INDEX auth_uname (uname)
);
# Topics
CREATE TABLE topics(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
touchedat DATETIME(3),
state SMALLINT NOT NULL DEFAULT 0,
stateat DATETIME(3),
name CHAR(25) NOT NULL,
usebt TINYINT DEFAULT 0,
owner BIGINT NOT NULL DEFAULT 0,
access JSON,
seqid INT NOT NULL DEFAULT 0,
delid INT DEFAULT 0,
subcnt INT DEFAULT 0,
public JSON,
trusted JSON,
tags JSON, -- Denormalized array of tags
aux JSON,
PRIMARY KEY(id),
UNIQUE INDEX topics_name (name),
INDEX topics_owner(owner),
INDEX topics_state_stateat(state, stateat),
INDEX topics_name_state_seqid ON topics(name, state, seqid)
);
# Indexed topic tags.
CREATE TABLE topictags(
id INT NOT NULL AUTO_INCREMENT,
topic CHAR(25) NOT NULL,
tag VARCHAR(96) NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(topic) REFERENCES topics(name),
INDEX topictags_tag (tag),
UNIQUE INDEX topictags_topic_tag(topic, tag)
);
# Subscriptions
CREATE TABLE subscriptions(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
deletedat DATETIME(3),
userid BIGINT NOT NULL,
topic CHAR(25) NOT NULL,
delid INT DEFAULT 0,
recvseqid INT DEFAULT 0,
readseqid INT DEFAULT 0,
modewant CHAR(8),
modegiven CHAR(8),
private JSON,
PRIMARY KEY(id) ,
FOREIGN KEY(userid) REFERENCES users(id),
UNIQUE INDEX subscriptions_topic_userid(topic, userid),
INDEX subscriptions_topic(topic),
INDEX subscriptions_deletedat(deletedat),
INDEX subscriptions_user_topic_deletedat ON subscriptions(userid, topic, deletedat)
);
# Messages
CREATE TABLE messages(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
deletedat DATETIME(3),
delid INT DEFAULT 0,
seqid INT NOT NULL,
topic CHAR(25) NOT NULL,
`from` BIGINT NOT NULL,
head JSON,
content JSON,
PRIMARY KEY(id),
FOREIGN KEY(topic) REFERENCES topics(name),
UNIQUE INDEX messages_topic_seqid (topic, seqid)
);
# Deletion log
CREATE TABLE dellog(
id INT NOT NULL AUTO_INCREMENT,
topic CHAR(25) NOT NULL,
deletedfor BIGINT NOT NULL DEFAULT 0,
delid INT NOT NULL,
low INT NOT NULL,
hi INT NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY(topic) REFERENCES topics(name),
# For getting the list of deleted message ranges
INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor),
# Used when getting not-yet-deleted messages(messages LEFT JOIN dellog)
INDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi),
# Used when deleting a user
INDEX dellog_deletedfor(deletedfor)
);
# User credentials
CREATE TABLE credentials(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
deletedat DATETIME(3),
method VARCHAR(16) NOT NULL,
value VARCHAR(128) NOT NULL,
synthetic VARCHAR(192) NOT NULL,
userid BIGINT NOT NULL,
resp VARCHAR(255) NOT NULL,
done TINYINT NOT NULL DEFAULT 0,
retries INT NOT NULL DEFAULT 0,
PRIMARY KEY(id),
UNIQUE credentials_uniqueness(synthetic),
FOREIGN KEY(userid) REFERENCES users(id),
);
# Records of uploaded files. Files themselves are stored elsewhere.
CREATE TABLE fileuploads(
id BIGINT NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
userid BIGINT,
status INT NOT NULL,
mimetype VARCHAR(255) NOT NULL,
size BIGINT NOT NULL,
location VARCHAR(2048) NOT NULL,
etag VARCHAR(128),
PRIMARY KEY(id),
INDEX fileuploads_status(status)
);
# Links between uploaded files and messages or topics.
CREATE TABLE filemsglinks(
id INT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
fileid BIGINT NOT NULL,
msgid INT,
topic CHAR(25),
userid BIGINT,
PRIMARY KEY(id),
FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE,
FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY(topicid) REFERENCES topics(id) ON DELETE CASCADE,
FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
{
"reset_db_data": true,
"adapters": {
"mysql": {
"dsn": "root@tcp(localhost:3306)/tinode_test?parseTime=true&collation=utf8mb4_unicode_ci",
// Name of the main database.
"database": "tinode_test"
}
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
# The MySQL and PostrgreSQL schemas are identical save for differences in SQL flavors.
# SEE ../mysql/schema.sql.
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"reset_db_data": true,
"adapters": {
"postgres": {
"User": "postgres",
"Passwd": "postgres",
"Host": "localhost",
"Port": "5432",
"DBName": "tinode_test"
}
}
}
File diff suppressed because it is too large Load Diff
+331
View File
@@ -0,0 +1,331 @@
# RethinkDB Database Schema
## Database `tinode`
### Table `users`
Stores user accounts
Fields:
* `Id` user id, primary key
* `CreatedAt` timestamp when the user was created
* `UpdatedAt` timestamp when user metadata was updated
* `State` account state: normal (ok), suspended, soft-deleted
* `StateAt` timestamp when the state was last updated or NULL
* `Access` user's default access level for peer-to-peer topics
* `Auth`, `Anon` default permissions for authenticated and anonymous users
* `Public` application-defined data
* `State` state of the user: normal, disabled, deleted
* `StateAt` timestamp when the state was last updated or NULL
* `LastSeen` timestamp when the user was last online
* `UserAgent` client User-Agent used when last online
* `Tags` unique strings for user discovery
* `Devices` client devices for push notifications
* `DeviceId` device registration ID
* `Platform` device platform string (iOS, Android, Web)
* `LastSeen` last logged in
* `Lang` device language, ISO code
Indexes:
* `Id` primary key
* `Tags` multi-index (indexed array)
* `DeletedAt` index
* `DeviceIds` multi-index of push notification tokens
Sample:
```js
{
"Access": {
"Anon": 0 ,
"Auth": 47
} ,
"CreatedAt": Mon Jul 24 2017 11:16:38 GMT+00:00 ,
"State": 0,
"StateAt": null ,
"Devices": null ,
"Id": "7yUCHniegrM" ,
"LastSeen": Mon Jan 01 1 00:00:00 GMT+00:00 ,
"Public": {
"fn": "Alice Johnson" ,
"photo": {
"data": <binary, 6.5KB, "ff d8 ff e0 00 10..."> ,
"type": "jpg"
}
} ,
"State": 1 ,
"Tags": [
"email:alice@example.com" ,
"tel:17025550001"
] ,
"UpdatedAt": Mon Jul 24 2017 11:16:38 GMT+00:00 ,
"UserAgent": "TinodeWeb/0.13 (MacIntel) tinodejs/0.13"
}
```
### Table `auth`
Stores authentication secrets
Fields:
* `userid` ID of the user who owns the record
* `unique` unique string which identifies this record, primary key; defined as "_authentication scheme_':'_some unique value per scheme_"
* `secret` shared secret, for instance bcrypt of password
* `authLvl` authentication level
* `expires` timestamp when the records expires
Indexes:
* `unique` primary key
* `userid` index
Sample:
```js
{
"authLvl": 20 ,
"expires": Mon Jan 01 1 00:00:00 GMT+00:00 ,
"secret": <binary, 60 bytes, "24 32 61 24 31 30..."> ,
"unique": "basic:alice" ,
"userid": "7yUCHniegrM"
}
```
### Table `topics`
The table stores topics.
Fields:
* `Id` name of the topic, primary key
* `CreatedAt` topic creation time
* `UpdatedAt` timestamp of the last change to topic metadata
* `State` topic state: normal (ok), suspended, soft-deleted
* `StateAt` timestamp when the state was last updated or NULL
* `Access` stores topic's default access permissions
* `Auth`, `Anon` permissions for authenticated and anonymous users respectively
* `Owner` ID of the user who owns the topic
* `Public` application-defined data
* `State` state of the topic: normal, disabled, deleted
* `SeqId` sequential ID of the last message
* `DelId` topic-sequential ID of the deletion operation
* `UseBt` indicator that channel functionality is enabled in the topic
Indexes:
* `Id` primary key
* `Owner` index
Sample:
```js
{
"Access": {
"Anon": 64 ,
"Auth": 64
} ,
"DelId": 0,
"CreatedAt": Thu Oct 15 2015 04:06:51 GMT+00:00 ,
"State": 0 ,
"StateAt": null ,
"LastMessageAt": Sat Oct 17 2015 13:51:56 GMT+00:00 ,
"Id": "p2pavVGHLCBbKrvJQIeeJ6Csw" ,
"Owner": "v2JyG4OLSoA" ,
"Public": {
"fn": "Travel, travel, travel" ,
"photo": {
"data": <binary, 6.2KB, "ff d8 ff e0 00 10..."> ,
"type": "jpg"
}
} ,
"SeqId": 14,
"State": 0 ,
"UpdatedAt": Thu Oct 15 2015 04:06:51 GMT+00:00 ,
"UseBt": false
}
```
### Table `subscriptions`
The table stores relationships between users and topics.
Fields:
* `Id` used for object retrieval
* `CreatedAt` timestamp when the subscription was created
* `UpdatedAt` timestamp when the subscription was updated
* `DeletedAt` timestamp when the subscription was deleted
* `ReadSeqId` id of the message last read by the user
* `RecvSeqId` id of the message last received by any user device
* `DelId` topic-sequential ID of the soft-deletion operation
* `Topic` name of the topic subscribed to
* `User` subscriber's user ID
* `ModeWant` access mode that user wants when accessing the topic
* `ModeGiven` access mode granted to user by the topic
* `Private` application-defined data, accessible by the user only
Indexes:
* `Id` primary key composed as "_topic name_':'_user ID_"
* `User` index
* `Topic` index
Sample:
```js
{
"ClearId": 0 ,
"CreatedAt": Tue Jul 25 2017 15:34:39 GMT+00:00 ,
"DeletedAt": null ,
"Id": "grpjajVKrHn0PU:v2JyG4OLSoA" ,
"ModeGiven": 47 ,
"ModeWant": 47 ,
"Private": "Kirgudu" ,
"ReadSeqId": 0 ,
"RecvSeqId": 0 ,
"State": 0 ,
"Topic": "grpjajVKrHn0PU" ,
"UpdatedAt": Tue Jul 25 2017 15:34:39 GMT+00:00 ,
"User": "v2JyG4OLSoA"
}
```
### Table `messages`
The table stores `{data}` messages
Fields:
* `Id` currently unused, primary key
* `CreatedAt` timestamp when the message was created
* `UpdatedAt` initially equal to CreatedAt, for deleted messages equal to DeletedAt
* `DeletedFor` array of user IDs which soft-deleted the message
* `DelId` topic-sequential ID of the soft-deletion operation
* `User` ID of the user who soft-deleted the message
* `From` ID of the user who generated this message
* `Topic` which received this message
* `SeqId` messages ID - sequential number of the message in the topic
* `Head` message headers
* `Attachments` denormalized IDs of files attached to the message
* `Content` application-defined message payload
Indexes:
* `Id` primary key
* `Topic_SeqId` compound index `["Topic", "SeqId"]`
* `Topic_DelId` compound index `["Topic", "DelId"]`
* `Topic_DeletedFor` compound multi-index `["Topic", "DeletedFor"("User"), "DeletedFor"("DelId")]`
Sample:
```js
{
"Content": {
"fmt": [
{
"len": 6 ,
"tp": "ST"
}
] ,
"txt": "Hello!"
} ,
"CreatedAt": Sun Dec 24 2017 05:16:23 GMT+00:00 ,
"From": "wTI0jO9rEqY" ,
"Head": {
"mime": "text/x-drafty"
} ,
"DeletedFor": [
{
"DelId": 1 ,
"User": "wTI0jO9rEqY"
}
] ,
"Id": "LLXKEe9W4Bs" ,
"SeqId": 3 ,
"Topic": "p2pJhbJnya8z5PBMjSM72sSpg" ,
"UpdatedAt": Sun Dec 24 2017 05:16:23 GMT+00:00
}
```
### Table `dellog`
The table stores records of message deletions
Fields:
* `Id` currently unused, primary key
* `CreatedAt` timestamp when the record was created
* `UpdatedAt` timestamp equal to CreatedAt
* `DelId` topic-sequential ID of the deletion operation.
* `DeletedFor` ID of the user for soft-deletions, blank string for hard-deletions
* `Topic` affected topic
* `SeqIdRanges` array of ranges of deleted message IDs (see `messages.SeqId`)
Indexes:
* `Id` primary key
* `Topic_DelId` compound index `["Topic", "DelId"]`
Sample:
```js
{
"Id": "9LfrjW349Rc",
"CreatedAt": Tue Dec 05 2017 01:51:38 GMT+00:00,
"DelId": 18,
"DeletedFor": "xY-YHx09-WI" ,
"SeqIdRanges": [
{
"Low": 20,
"Hi": 25,
}
] ,
"Topic": "grpGx7fpjQwVC0" ,
"UpdatedAt": Tue Dec 05 2017 01:51:38 GMT+00:00
}
```
### Table `credentials`
The tables stores user credentials used for validation.
* `Id` credential, primary key
* `CreatedAt` timestamp when the record was created
* `UpdatedAt` timestamp when the last validation attempt was performed (successful or not).
* `Method` validation method
* `Done` indicator if the credential is validated
* `Resp` expected validation response
* `Retries` number of failed attempts at validation
* `User` id of the user who owns this credential
* `Value` value of the credential
* `Closed` unvalidated credential is no longer being validated. Only one credential is not Closed for each user/method.
Indexes:
* `Id` Primary key composed either as `User`:`Method`:`Value` for unconfirmed credentials or as `Method`:`Value` for confirmed.
* `User` Index
Sample:
```js
{
"Id": "tel:+17025550001",
"CreatedAt": Sun Jun 10 2018 16:37:27 GMT+00:00 ,
"UpdatedAt": Sun Jun 10 2018 16:37:28 GMT+00:00 ,
"Method": "tel" ,
"Done": true ,
"Resp": "123456" ,
"Retries": 0 ,
"User": "k3srBRk9RYw" ,
"Value": "+17025550001"
}
```
### Table `fileuploads`
The table stores records of uploaded files. The files themselves are stored outside of the database.
* `Id` unique user-visible file name, primary key
* `CreatedAt` timestamp when the record was created
* `UpdatedAt` timestamp of when th upload has cmpleted or failed
* `User` id of the user who uploaded this file.
* `Location` actual location of the file on the server.
* `MimeType` file content type as a [Mime](https://en.wikipedia.org/wiki/MIME) string.
* `Size` size of the file in bytes. Could be 0 if upload has not completed yet.
* `UseCount` count of messages referencing this file.
* `Status` upload status: 0 pending, 1 completed, -1 failed.
Indexes:
* `Id` primary key
* `UseCount` index
Sample:
```js
{
"CreatedAt": Sun Jun 10 2018 16:38:45 GMT+00:00 ,
"Id": "sFmjlQ_kA6A" ,
"Location": "uploads/sFmjlQ_kA6A" ,
"MimeType": "image/jpeg" ,
"Size": 54961090 ,
"UseCount": 3,
"Status": 1,
"UpdatedAt": Sun Jun 10 2018 16:38:45 GMT+00:00 ,
"User": "7j-RR1V7O3Y"
}
```
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
{
"reset_db_data": true,
"adapters": {
"rethinkdb": {
// Address(es) of RethinkDB node(s): either a string or an array of strings.
"addresses": "localhost:28015",
// Name of the main database.
"database": "tinode_test"
}
}
}