snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
This commit is contained in:
34
server/pkg/revisions/model.go
Normal file
34
server/pkg/revisions/model.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package revisions
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/id"
|
||||
)
|
||||
|
||||
const (
|
||||
RevisionResourceType = "corteza::system:revision"
|
||||
)
|
||||
|
||||
// Model returns generic dal.Model for storing revisions
|
||||
//
|
||||
// Returns only basic
|
||||
func Model() *dal.Model {
|
||||
// make revision model
|
||||
return &dal.Model{
|
||||
ResourceID: id.Next(),
|
||||
|
||||
Ident: "revisions",
|
||||
|
||||
ResourceType: RevisionResourceType,
|
||||
Attributes: dal.AttributeSet{
|
||||
&dal.Attribute{Ident: "id", PrimaryKey: true, Store: &dal.CodecPlain{}, Type: &dal.TypeID{}},
|
||||
&dal.Attribute{Ident: "ts", Store: &dal.CodecPlain{}, Type: &dal.TypeTimestamp{}},
|
||||
&dal.Attribute{Ident: "rel_resource", Store: &dal.CodecPlain{}, Type: &dal.TypeID{}},
|
||||
&dal.Attribute{Ident: "revision", Store: &dal.CodecPlain{}, Type: &dal.TypeNumber{}},
|
||||
&dal.Attribute{Ident: "operation", Store: &dal.CodecPlain{}, Type: &dal.TypeText{}},
|
||||
&dal.Attribute{Ident: "rel_user", Store: &dal.CodecPlain{}, Type: &dal.TypeID{}},
|
||||
&dal.Attribute{Ident: "delta", Store: &dal.CodecPlain{}, Type: &dal.TypeJSON{}},
|
||||
&dal.Attribute{Ident: "comment", Store: &dal.CodecPlain{}, Type: &dal.TypeText{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
14
server/pkg/revisions/operations.go
Normal file
14
server/pkg/revisions/operations.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package revisions
|
||||
|
||||
type (
|
||||
Operation = string
|
||||
)
|
||||
|
||||
const (
|
||||
unknown Operation = ""
|
||||
Created = "created"
|
||||
Updated = "updated"
|
||||
SoftDeleted = "soft-deleted"
|
||||
Undeleted = "undeleted"
|
||||
HardDeleted = "hard-deleted"
|
||||
)
|
||||
270
server/pkg/revisions/revisions.go
Normal file
270
server/pkg/revisions/revisions.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package revisions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/cast2"
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/cortezaproject/corteza/server/pkg/id"
|
||||
"github.com/cortezaproject/corteza/server/pkg/j7s"
|
||||
)
|
||||
|
||||
type (
|
||||
// generic revision struct
|
||||
Revision struct {
|
||||
ID uint64 `json:"changeID,string"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
ResourceID uint64 `json:"resourceID,string"`
|
||||
|
||||
Revision int `json:"revision"`
|
||||
Operation Operation `json:"operation"`
|
||||
|
||||
UserID uint64 `json:"userID,string"`
|
||||
|
||||
Changes []*Change `json:"changes"`
|
||||
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
Change struct {
|
||||
// changed field
|
||||
Key string `json:"key"`
|
||||
|
||||
Old []any `json:"old,omitempty"`
|
||||
New []any `json:"new,omitempty"`
|
||||
}
|
||||
|
||||
Filter struct {
|
||||
ResourceID uint64 `json:"resourceID,string"`
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
now = func() time.Time {
|
||||
return time.Now().Round(time.Second)
|
||||
}
|
||||
)
|
||||
|
||||
func Make(op Operation, revision int, resourceID, userID uint64) (rev *Revision) {
|
||||
return &Revision{
|
||||
ID: id.Next(),
|
||||
Timestamp: now(),
|
||||
ResourceID: resourceID,
|
||||
Revision: revision,
|
||||
UserID: userID,
|
||||
Operation: op,
|
||||
}
|
||||
}
|
||||
|
||||
// untested
|
||||
func (r *Revision) CollectChanges(new, old dal.ValueGetter, skip ...string) (err error) {
|
||||
var (
|
||||
cc = make([]*Change, 0)
|
||||
ch *Change
|
||||
|
||||
// old values count
|
||||
ovc map[string]uint
|
||||
|
||||
val any
|
||||
)
|
||||
|
||||
if new == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if old != nil {
|
||||
// old values count
|
||||
ovc = old.CountValues()
|
||||
|
||||
}
|
||||
|
||||
keys:
|
||||
for key, count := range new.CountValues() {
|
||||
if count == 0 && old == nil {
|
||||
continue keys
|
||||
}
|
||||
|
||||
for _, s := range skip {
|
||||
if key == s {
|
||||
continue keys
|
||||
}
|
||||
}
|
||||
|
||||
ch = &Change{
|
||||
Key: key,
|
||||
New: make([]any, 0, count),
|
||||
Old: make([]any, 0, count),
|
||||
}
|
||||
|
||||
for pos := uint(0); pos < count; pos++ {
|
||||
val, err = new.GetValue(key, pos)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ch.New = append(ch.New, val)
|
||||
}
|
||||
|
||||
if old == nil {
|
||||
cc = append(cc, ch)
|
||||
continue
|
||||
}
|
||||
|
||||
for pos := uint(0); pos < ovc[key]; pos++ {
|
||||
val, err = old.GetValue(key, pos)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ch.Old = append(ch.Old, val)
|
||||
}
|
||||
|
||||
if len(ch.New) != len(ch.Old) {
|
||||
// different sizes, means that something has changed
|
||||
cc = append(cc, ch)
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range ch.New {
|
||||
// go over all and append on first found difference
|
||||
if ch.New[i] != ch.Old[i] {
|
||||
cc = append(cc, ch)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.Changes = cc
|
||||
return
|
||||
}
|
||||
|
||||
// CountValues satisfies dal.ValueGetter interface
|
||||
func (r *Revision) CountValues() map[string]uint {
|
||||
// signaling DAL that each attribute has exactly one value!
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetValue satisfies dal.ValueGetter interface
|
||||
func (r *Revision) GetValue(ident string, _ uint) (any, error) {
|
||||
switch ident {
|
||||
case "id":
|
||||
return r.ID, nil
|
||||
|
||||
case "ts":
|
||||
return r.Timestamp, nil
|
||||
|
||||
case "rel_resource":
|
||||
return r.ResourceID, nil
|
||||
|
||||
case "revision":
|
||||
return r.Revision, nil
|
||||
|
||||
case "operation":
|
||||
return r.Operation, nil
|
||||
|
||||
case "rel_user":
|
||||
return r.UserID, nil
|
||||
|
||||
case "delta":
|
||||
return r.Changes, nil
|
||||
|
||||
case "comment":
|
||||
return r.Comment, nil
|
||||
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *Revision) SetValue(name string, _ uint, value any) error {
|
||||
switch name {
|
||||
case "id":
|
||||
return cast2.Uint64(value, &r.ID)
|
||||
|
||||
case "ts":
|
||||
return cast2.Time(value, &r.Timestamp)
|
||||
|
||||
case "rel_resource":
|
||||
return cast2.Uint64(value, &r.ResourceID)
|
||||
|
||||
case "revision":
|
||||
return cast2.Int(value, &r.Revision)
|
||||
|
||||
case "operation":
|
||||
return cast2.String(value, &r.Operation)
|
||||
|
||||
case "rel_user":
|
||||
return cast2.Uint64(value, &r.UserID)
|
||||
|
||||
case "delta":
|
||||
if bb, is := value.([]byte); is {
|
||||
decoder := json.NewDecoder(bytes.NewReader(bb))
|
||||
decoder.UseNumber()
|
||||
return decoder.Decode(&r.Changes)
|
||||
}
|
||||
|
||||
return fmt.Errorf("unexpected type for delta: %T", value)
|
||||
|
||||
case "comment":
|
||||
return cast2.String(value, &r.Comment)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f Filter) Constraints() map[string][]any {
|
||||
return map[string][]any{
|
||||
"rel_resource": {f.ResourceID},
|
||||
}
|
||||
}
|
||||
|
||||
func (f Filter) Expression() string { return "" }
|
||||
func (f Filter) OrderBy() filter.SortExprSet { return nil }
|
||||
func (f Filter) Limit() uint { return 0 }
|
||||
func (f Filter) Cursor() *filter.PagingCursor { return nil }
|
||||
func (f Filter) StateConstraints() map[string]filter.State { return nil }
|
||||
|
||||
func (c *Change) MarshalJSON() ([]byte, error) {
|
||||
row, err := j7s.MakeMap("key", c.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if old := normalizeChangeValues(c.Old); len(old) > 0 {
|
||||
row, err = j7s.AddMap(row, "old", old)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if newVals := normalizeChangeValues(c.New); len(newVals) > 0 {
|
||||
row, err = j7s.AddMap(row, "new", newVals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(row)
|
||||
}
|
||||
|
||||
func normalizeChangeValues(values []any) []any {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]any, 0, len(values))
|
||||
for _, v := range values {
|
||||
if jn, ok := v.(json.Number); ok {
|
||||
out = append(out, jn.String())
|
||||
} else {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
253
server/pkg/revisions/revisions_test.go
Normal file
253
server/pkg/revisions/revisions_test.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package revisions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Note: These tests use json.Number because when reading revisions from DB,
|
||||
// SetValue uses decoder.UseNumber() which decodes JSON numbers as json.Number
|
||||
// (not uint64 or float64). This preserves precision for large uint64 values.
|
||||
|
||||
func Test_normalizeChangeValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input []any
|
||||
expected []any
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
[]any{},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"nil",
|
||||
nil,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"json.Number to string",
|
||||
[]any{json.Number("123456789012345")},
|
||||
[]any{"123456789012345"},
|
||||
},
|
||||
{
|
||||
"strings unchanged",
|
||||
[]any{"test", "value"},
|
||||
[]any{"test", "value"},
|
||||
},
|
||||
{
|
||||
"booleans unchanged",
|
||||
[]any{true, false},
|
||||
[]any{true, false},
|
||||
},
|
||||
{
|
||||
"mixed types",
|
||||
[]any{json.Number("999"), "text", true, json.Number("12345678901234567890")},
|
||||
[]any{"999", "text", true, "12345678901234567890"},
|
||||
},
|
||||
{
|
||||
"regular numbers unchanged",
|
||||
[]any{int64(123), float64(45.67)},
|
||||
[]any{int64(123), float64(45.67)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
result = normalizeChangeValues(c.input)
|
||||
)
|
||||
req.Equal(c.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Change_MarshalJSON(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
change *Change
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"strings",
|
||||
&Change{
|
||||
Key: "name",
|
||||
Old: []any{"old name"},
|
||||
New: []any{"new name"},
|
||||
},
|
||||
`{"key":"name","new":["new name"],"old":["old name"]}`,
|
||||
},
|
||||
{
|
||||
"json.Number to strings",
|
||||
&Change{
|
||||
Key: "ownedBy",
|
||||
Old: []any{json.Number("123456789012345")},
|
||||
New: []any{json.Number("987654321098765")},
|
||||
},
|
||||
`{"key":"ownedBy","new":["987654321098765"],"old":["123456789012345"]}`,
|
||||
},
|
||||
{
|
||||
"mixed types",
|
||||
&Change{
|
||||
Key: "status",
|
||||
Old: []any{json.Number("1"), "active", true},
|
||||
New: []any{json.Number("2"), "inactive", false},
|
||||
},
|
||||
`{"key":"status","new":["2","inactive",false],"old":["1","active",true]}`,
|
||||
},
|
||||
{
|
||||
"empty old",
|
||||
&Change{
|
||||
Key: "newField",
|
||||
Old: []any{},
|
||||
New: []any{"value"},
|
||||
},
|
||||
`{"key":"newField","new":["value"]}`,
|
||||
},
|
||||
{
|
||||
"empty new (deletion)",
|
||||
&Change{
|
||||
Key: "deletedField",
|
||||
Old: []any{"value"},
|
||||
New: []any{},
|
||||
},
|
||||
`{"key":"deletedField","old":["value"]}`,
|
||||
},
|
||||
{
|
||||
"large uint64 values",
|
||||
&Change{
|
||||
Key: "createdBy",
|
||||
Old: []any{json.Number("18446744073709551615")},
|
||||
New: []any{json.Number("18446744073709551614")},
|
||||
},
|
||||
`{"key":"createdBy","new":["18446744073709551614"],"old":["18446744073709551615"]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
result, err = c.change.MarshalJSON()
|
||||
)
|
||||
req.NoError(err)
|
||||
req.JSONEq(c.expected, string(result))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Revision_SetValue_Delta_UseNumber(t *testing.T) {
|
||||
t.Run("large uint64 preserved as json.Number", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
r = &Revision{}
|
||||
|
||||
deltaJSON = []byte(`[
|
||||
{
|
||||
"key": "ownedBy",
|
||||
"old": [123456789012345],
|
||||
"new": [987654321098765]
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"old": ["old"],
|
||||
"new": ["new"]
|
||||
}
|
||||
]`)
|
||||
)
|
||||
|
||||
err := r.SetValue("delta", 0, deltaJSON)
|
||||
req.NoError(err)
|
||||
req.Len(r.Changes, 2)
|
||||
|
||||
req.Equal("ownedBy", r.Changes[0].Key)
|
||||
req.Len(r.Changes[0].Old, 1)
|
||||
req.Len(r.Changes[0].New, 1)
|
||||
|
||||
oldVal, ok := r.Changes[0].Old[0].(json.Number)
|
||||
req.True(ok, "Old value should be json.Number")
|
||||
req.Equal("123456789012345", oldVal.String())
|
||||
|
||||
newVal, ok := r.Changes[0].New[0].(json.Number)
|
||||
req.True(ok, "New value should be json.Number")
|
||||
req.Equal("987654321098765", newVal.String())
|
||||
|
||||
req.Equal("name", r.Changes[1].Key)
|
||||
req.Equal("old", r.Changes[1].Old[0])
|
||||
req.Equal("new", r.Changes[1].New[0])
|
||||
})
|
||||
|
||||
t.Run("round-trip preserves precision", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
original = &Change{
|
||||
Key: "userId",
|
||||
Old: []any{json.Number("18446744073709551615")},
|
||||
New: []any{json.Number("18446744073709551614")},
|
||||
}
|
||||
)
|
||||
|
||||
marshaled, err := original.MarshalJSON()
|
||||
req.NoError(err)
|
||||
|
||||
var intermediate map[string]any
|
||||
err = json.Unmarshal(marshaled, &intermediate)
|
||||
req.NoError(err)
|
||||
req.Equal("userId", intermediate["key"])
|
||||
|
||||
oldSlice := intermediate["old"].([]any)
|
||||
req.Equal("18446744073709551615", oldSlice[0])
|
||||
|
||||
newSlice := intermediate["new"].([]any)
|
||||
req.Equal("18446744073709551614", newSlice[0])
|
||||
})
|
||||
}
|
||||
|
||||
func Test_Revision_MarshalJSON_Integration(t *testing.T) {
|
||||
t.Run("full revision with uint64 strings", func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
r = &Revision{
|
||||
ID: 1234567890123456789,
|
||||
ResourceID: 9876543210987654321,
|
||||
UserID: 1111111111111111111,
|
||||
Revision: 5,
|
||||
Operation: "update",
|
||||
Comment: "Test revision",
|
||||
Changes: []*Change{
|
||||
{
|
||||
Key: "ownedBy",
|
||||
Old: []any{json.Number("123456789012345")},
|
||||
New: []any{json.Number("987654321098765")},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
data, err := json.Marshal(r)
|
||||
req.NoError(err)
|
||||
|
||||
var result map[string]any
|
||||
err = json.Unmarshal(data, &result)
|
||||
req.NoError(err)
|
||||
|
||||
req.Equal("1234567890123456789", result["changeID"])
|
||||
req.Equal("9876543210987654321", result["resourceID"])
|
||||
req.Equal("1111111111111111111", result["userID"])
|
||||
|
||||
changes := result["changes"].([]any)
|
||||
req.Len(changes, 1)
|
||||
|
||||
change := changes[0].(map[string]any)
|
||||
req.Equal("ownedBy", change["key"])
|
||||
|
||||
old := change["old"].([]any)
|
||||
req.Equal("123456789012345", old[0])
|
||||
|
||||
new := change["new"].([]any)
|
||||
req.Equal("987654321098765", new[0])
|
||||
})
|
||||
}
|
||||
37
server/pkg/revisions/service.go
Normal file
37
server/pkg/revisions/service.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package revisions
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/dal"
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
)
|
||||
|
||||
type (
|
||||
Servicer interface {
|
||||
Search(ctx context.Context, mf dal.ModelRef, f filter.Filter) (_ dal.Iterator, err error)
|
||||
Create(ctx context.Context, mf dal.ModelRef, revision *Revision) error
|
||||
}
|
||||
|
||||
creatorSearcher interface {
|
||||
Search(ctx context.Context, m dal.ModelRef, operations dal.OperationSet, f filter.Filter) (dal.Iterator, error)
|
||||
Create(ctx context.Context, m dal.ModelRef, operations dal.OperationSet, vv ...dal.ValueGetter) error
|
||||
}
|
||||
|
||||
service struct {
|
||||
dal creatorSearcher
|
||||
}
|
||||
)
|
||||
|
||||
func Service(dal creatorSearcher) *service {
|
||||
return &service{dal: dal}
|
||||
}
|
||||
|
||||
func (svc *service) Search(ctx context.Context, mf dal.ModelRef, f filter.Filter) (_ dal.Iterator, err error) {
|
||||
return svc.dal.Search(ctx, mf, dal.OperationSet{dal.Search}, f)
|
||||
}
|
||||
|
||||
func (svc *service) Create(ctx context.Context, mf dal.ModelRef, revision *Revision) error {
|
||||
return svc.dal.Create(ctx, mf, dal.OperationSet{dal.Create}, revision)
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user