Fix canonical archive writes and retain scoped lifecycle evidence
This commit is contained in:
@@ -24,11 +24,13 @@ type modelCanvas struct {
|
||||
Parent *modelParent `json:"parent"`
|
||||
}
|
||||
type canvasModel struct {
|
||||
Root string
|
||||
Canvases map[string]modelCanvas
|
||||
Elements map[string]map[string]any
|
||||
Tombstones map[string]int64
|
||||
Versions map[string]int64
|
||||
Root string
|
||||
Canvases map[string]modelCanvas
|
||||
Elements map[string]map[string]any
|
||||
Tombstones map[string]int64
|
||||
TombstoneOwners map[string]string
|
||||
DeadRecords map[string]map[string]any
|
||||
Versions map[string]int64
|
||||
}
|
||||
type modelView struct {
|
||||
Format int `json:"documentFormatVersion"`
|
||||
@@ -66,7 +68,7 @@ type modelBatch struct {
|
||||
}
|
||||
|
||||
func emptyModel(root string) canvasModel {
|
||||
return canvasModel{Root: root, Canvases: map[string]modelCanvas{}, Elements: map[string]map[string]any{}, Tombstones: map[string]int64{}, Versions: map[string]int64{}}
|
||||
return canvasModel{Root: root, Canvases: map[string]modelCanvas{}, Elements: map[string]map[string]any{}, Tombstones: map[string]int64{}, TombstoneOwners: map[string]string{}, DeadRecords: map[string]map[string]any{}, Versions: map[string]int64{}}
|
||||
}
|
||||
func modelString(value any) string { text, _ := value.(string); return text }
|
||||
func modelInt(value any) int64 {
|
||||
@@ -76,6 +78,32 @@ func modelInt(value any) int64 {
|
||||
}
|
||||
return int64(number)
|
||||
}
|
||||
func modelJSONSafe(value any, depth int) bool {
|
||||
if depth > 64 {
|
||||
return false
|
||||
}
|
||||
switch value := value.(type) {
|
||||
case nil, bool, string:
|
||||
return true
|
||||
case float64:
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
case []any:
|
||||
for _, item := range value {
|
||||
if !modelJSONSafe(item, depth+1) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case map[string]any:
|
||||
for key, item := range value {
|
||||
if canvasChoice(key, "__proto__", "constructor", "prototype") || !modelJSONSafe(item, depth+1) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func modelEqual(left, right any) bool {
|
||||
// Round-trip numeric Go structs and JSON records to the same JSON value domain.
|
||||
a, ea := json.Marshal(left)
|
||||
@@ -106,8 +134,16 @@ func (model canvasModel) view(participants map[string]int64) modelView {
|
||||
}
|
||||
view.Elements = append(view.Elements, element)
|
||||
}
|
||||
// Tombstones are small identity facts, not payloads; include them for safe incarnation checks.
|
||||
for id, incarnation := range model.Tombstones {
|
||||
owner := model.TombstoneOwners[id]
|
||||
if live := model.Elements[id]; live != nil {
|
||||
owner = modelString(live["ownerCanvasId"])
|
||||
}
|
||||
if participants != nil {
|
||||
if _, exists := participants[owner]; !exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
view.Tombstones[id] = incarnation
|
||||
}
|
||||
for _, id := range sortedKeys(model.Versions) {
|
||||
@@ -207,6 +243,9 @@ func validateCanvasModel(model canvasModel) error {
|
||||
}
|
||||
}
|
||||
for id, element := range model.Elements {
|
||||
if !modelJSONSafe(element, 0) {
|
||||
return errCanvasModel
|
||||
}
|
||||
owner, component := modelString(element["ownerCanvasId"]), modelString(element["componentId"])
|
||||
_, canvasExists := model.Canvases[owner]
|
||||
incarnation, version := modelInt(element["incarnation"]), modelInt(element["dataVersion"])
|
||||
@@ -333,6 +372,8 @@ func applyModelOperations(initial canvasModel, operations []modelOperation) (can
|
||||
if after == nil {
|
||||
if prior := model.Elements[change.ID]; prior != nil {
|
||||
model.Tombstones[change.ID] = modelInt(prior["incarnation"])
|
||||
model.TombstoneOwners[change.ID] = modelString(prior["ownerCanvasId"])
|
||||
model.DeadRecords[change.ID] = prior
|
||||
}
|
||||
delete(model.Elements, change.ID)
|
||||
} else {
|
||||
@@ -340,7 +381,7 @@ func applyModelOperations(initial canvasModel, operations []modelOperation) (can
|
||||
if !ok || record["id"] != change.ID {
|
||||
return model, nil, errCanvasModel
|
||||
}
|
||||
if before == nil && modelInt(record["incarnation"]) != model.Tombstones[change.ID]+1 {
|
||||
if before == nil && modelInt(record["incarnation"]) <= model.Tombstones[change.ID] {
|
||||
return model, nil, errModelConflict
|
||||
}
|
||||
if prior := model.Elements[change.ID]; prior != nil && modelInt(record["incarnation"]) != modelInt(prior["incarnation"]) {
|
||||
|
||||
@@ -415,6 +415,18 @@ func (s *service) submitCanvasModelOperations(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range after.DeadRecords {
|
||||
if before.Elements[id] != nil || before.Tombstones[id] > 0 {
|
||||
continue
|
||||
}
|
||||
var foreign bool
|
||||
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_model_elements WHERE project=$1 AND element_id=$2 AND (workspace_id<>$3 OR root_id<>$4))`, project, id, workspace, root).Scan(&foreign); err != nil {
|
||||
return err
|
||||
}
|
||||
if foreign {
|
||||
return rejectBatch(409, "element_identity_conflict")
|
||||
}
|
||||
}
|
||||
if err = s.saveCanvasModel(r, tx, project, workspace, account, before, after, touched); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -214,4 +214,26 @@ func TestCanonicalModelMigrationAndIncrementalLifecycle(t *testing.T) {
|
||||
if call("GET", base+"/model", nil, "", 200)["model"].(map[string]any)["complete"] != true {
|
||||
t.Fatal("initial read has no explicit coverage")
|
||||
}
|
||||
// An object created and removed inside one transaction still has a lifecycle fact.
|
||||
ghostID := modelTestID(4000)
|
||||
ghost := modelTestNode(ghostID, root)
|
||||
edit(1008, map[string]int64{root: 6}, modelChange{Kind: "element", ID: ghostID, Before: modelJSON(nil), After: modelJSON(ghost)}, modelChange{Kind: "element", ID: ghostID, Before: modelJSON(ghost), After: modelJSON(nil)})
|
||||
model = read()
|
||||
if model.Tombstones[ghostID] != 1 || model.Elements[ghostID] != nil {
|
||||
t.Fatal("in-batch create/delete lost its tombstone")
|
||||
}
|
||||
ghost["incarnation"] = float64(2)
|
||||
edit(1009, map[string]int64{root: 7}, modelChange{Kind: "element", ID: ghostID, Before: modelJSON(nil), After: modelJSON(ghost)})
|
||||
if modelInt(read().Elements[ghostID]["incarnation"]) != 2 {
|
||||
t.Fatal("restore after an in-batch delete did not preserve identity")
|
||||
}
|
||||
// Session changes cannot send an old account's queued request as a new account.
|
||||
request := httptest.NewRequest("GET", base+"/model", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-Expected-Account", modelTestID(7777))
|
||||
writer := httptest.NewRecorder()
|
||||
mux.ServeHTTP(writer, request)
|
||||
if writer.Code != 409 {
|
||||
t.Fatal("expected-account barrier was ignored")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,12 +60,17 @@ func loadCanvasModel(ctx context.Context, tx *sql.Tx, project, workspace, root s
|
||||
if dead > 0 {
|
||||
model.Tombstones[id] = dead
|
||||
}
|
||||
var element map[string]any
|
||||
if err = json.Unmarshal(raw, &element); err != nil {
|
||||
return model, err
|
||||
}
|
||||
if dead > 0 {
|
||||
model.TombstoneOwners[id] = modelString(element["ownerCanvasId"])
|
||||
}
|
||||
if !deleted {
|
||||
var element map[string]any
|
||||
if err = json.Unmarshal(raw, &element); err != nil {
|
||||
return model, err
|
||||
}
|
||||
model.Elements[id] = element
|
||||
} else {
|
||||
model.DeadRecords[id] = element
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
@@ -131,11 +136,26 @@ func (s *service) saveCanvasModel(r *http.Request, tx *sql.Tx, project, workspac
|
||||
return errModelConflict
|
||||
}
|
||||
}
|
||||
for _, id := range sortedKeys(before.Elements) {
|
||||
if after.Elements[id] == nil {
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE canvas_model_elements SET deleted=true,deleted_incarnation=incarnation WHERE project=$1 AND workspace_id=$2 AND root_id=$3 AND element_id=$4`, project, workspace, after.Root, id); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range sortedKeys(after.Tombstones) {
|
||||
if after.Elements[id] != nil || before.Elements[id] == nil && before.Tombstones[id] == after.Tombstones[id] {
|
||||
continue
|
||||
}
|
||||
record := after.DeadRecords[id]
|
||||
if record == nil {
|
||||
return errCanvasModel
|
||||
}
|
||||
raw, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err := tx.ExecContext(ctx, `INSERT INTO canvas_model_elements(project,workspace_id,root_id,element_id,owner_canvas_id,incarnation,deleted_incarnation,record,deleted) VALUES($1,$2,$3,$4,$5,$6,$6,$7,true) ON CONFLICT(project,element_id) DO UPDATE SET owner_canvas_id=EXCLUDED.owner_canvas_id,incarnation=EXCLUDED.incarnation,deleted_incarnation=EXCLUDED.deleted_incarnation,record=EXCLUDED.record,deleted=true WHERE canvas_model_elements.workspace_id=EXCLUDED.workspace_id AND canvas_model_elements.root_id=EXCLUDED.root_id`, project, workspace, after.Root, id, record["ownerCanvasId"], after.Tombstones[id], raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, err := written.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if count != 1 {
|
||||
return errModelConflict
|
||||
}
|
||||
}
|
||||
// Archive relations first; parent/entry changes then install the final owning tree.
|
||||
@@ -164,7 +184,7 @@ func (s *service) saveCanvasModel(r *http.Request, tx *sql.Tx, project, workspac
|
||||
}
|
||||
for _, id := range sortedKeys(participants) {
|
||||
if _, active := after.Canvases[id]; !active {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE materials SET archived_at=COALESCE(archived_at,now()),canvas_version=$4 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id, after.Versions[id])
|
||||
_, err = tx.ExecContext(ctx, `UPDATE materials SET canvas_version=$4 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id, after.Versions[id])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -174,9 +194,6 @@ func (s *service) saveCanvasModel(r *http.Request, tx *sql.Tx, project, workspac
|
||||
if err = s.saveCanvas(r, tx, project, workspace, canvasSnapshot{BoardID: id, Version: after.Versions[id], Document: projection[id]}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE materials SET archived_at=NULL WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, Idempotency-Key")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, Idempotency-Key, X-Expected-Account")
|
||||
reply(w, 204, nil)
|
||||
return
|
||||
}
|
||||
@@ -88,6 +88,10 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H
|
||||
account := session.AccountID
|
||||
if err == nil {
|
||||
auth.WriteSessionHeaders(w, session)
|
||||
if expected := r.Header.Get("X-Expected-Account"); expected != "" && expected != account {
|
||||
reject(w, 409, "identity_changed")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err == nil && r.PathValue("workspace") != "" {
|
||||
var id string
|
||||
|
||||
Reference in New Issue
Block a user