chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:19:22 +08:00
commit 6366859bb3
222 changed files with 47313 additions and 0 deletions

View File

@@ -0,0 +1,514 @@
package blueprint
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var identRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,47}$`)
var nonIdentRe = regexp.MustCompile(`[^a-z0-9_]+`)
var multiUnderRe = regexp.MustCompile(`_+`)
var reserved = map[string]struct{}{
"select": {}, "insert": {}, "update": {}, "delete": {}, "drop": {},
"create": {}, "alter": {}, "table": {}, "schema": {}, "user": {},
"where": {}, "from": {}, "join": {}, "grant": {}, "revoke": {},
}
type Blueprint struct {
Version string `json:"version"`
Meta Meta `json:"meta"`
Storage Storage `json:"storage"`
Entities []Entity `json:"entities"`
Apis Apis `json:"apis"`
Pages []Page `json:"pages"`
Security Security `json:"security"`
Seed *Seed `json:"seed,omitempty"`
}
type Meta struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description,omitempty"`
Locale string `json:"locale"`
Confidence float64 `json:"confidence,omitempty"`
UIPreset string `json:"ui_preset,omitempty"`
PlatformTitle string `json:"platform_title,omitempty"`
ProjectContext string `json:"project_context,omitempty"`
UI json.RawMessage `json:"ui,omitempty"`
Source json.RawMessage `json:"source,omitempty"`
}
type Storage struct {
Mode string `json:"mode"`
Engine string `json:"engine"`
SchemaName string `json:"schema_name,omitempty"`
}
type Entity struct {
Name string `json:"name"`
Table string `json:"table"`
Label string `json:"label"`
PrimaryKey string `json:"primary_key"`
Fields []Field `json:"fields"`
Indexes []Index `json:"indexes,omitempty"`
}
type Field struct {
Name string `json:"name"`
Label string `json:"label"`
Type string `json:"type"`
Nullable *bool `json:"nullable,omitempty"`
Unique bool `json:"unique,omitempty"`
Default any `json:"default,omitempty"`
MaxLength int `json:"max_length,omitempty"`
Precision int `json:"precision,omitempty"`
Scale int `json:"scale,omitempty"`
EnumValues []string `json:"enum_values,omitempty"`
UI json.RawMessage `json:"ui,omitempty"`
}
type Index struct {
Name string `json:"name"`
Columns []string `json:"columns"`
Unique bool `json:"unique,omitempty"`
}
type Apis struct {
BasePath string `json:"base_path"`
Resources []APIResource `json:"resources"`
}
type APIResource struct {
Entity string `json:"entity"`
Path string `json:"path"`
Operations []string `json:"operations"`
List *ListOpt `json:"list,omitempty"`
}
type ListOpt struct {
DefaultPageSize int `json:"default_page_size,omitempty"`
MaxPageSize int `json:"max_page_size,omitempty"`
AllowedFilters []string `json:"allowed_filters,omitempty"`
AllowedSorts []string `json:"allowed_sorts,omitempty"`
}
type Page struct {
ID string `json:"id"`
Title string `json:"title"`
Route string `json:"route"`
Type string `json:"type"`
Entity string `json:"entity"`
Layout *PageLayout `json:"layout,omitempty"`
}
type PageLayout struct {
Preset string `json:"preset,omitempty"`
Columns []string `json:"columns,omitempty"`
Filters []string `json:"filters,omitempty"`
Actions []string `json:"actions,omitempty"`
ActionLabels map[string]string `json:"action_labels,omitempty"`
FilterStyle string `json:"filter_style,omitempty"`
FormFields []string `json:"form_fields,omitempty"`
Widgets []PageWidget `json:"widgets,omitempty"`
}
type PageWidget struct {
Type string `json:"type"`
Title string `json:"title,omitempty"`
Metric string `json:"metric,omitempty"`
Metrics []string `json:"metrics,omitempty"`
GroupBy string `json:"group_by,omitempty"`
XField string `json:"x_field,omitempty"`
Entity string `json:"entity,omitempty"`
Columns []string `json:"columns,omitempty"`
LabelField string `json:"label_field,omitempty"`
ValueField string `json:"value_field,omitempty"`
SecondaryField string `json:"secondary_field,omitempty"`
WarnField string `json:"warn_field,omitempty"`
FilterField string `json:"filter_field,omitempty"`
FilterOp string `json:"filter_op,omitempty"`
FilterValue any `json:"filter_value,omitempty"`
YUnit string `json:"y_unit,omitempty"`
Variant string `json:"variant,omitempty"`
CycleDays int `json:"cycle_days,omitempty"`
}
type Security struct {
Visibility string `json:"visibility"`
Roles []Role `json:"roles"`
RowPolicies []RowPolicy `json:"row_policies"`
}
type Role struct {
Name string `json:"name"`
Permissions []string `json:"permissions"`
}
type RowPolicy struct {
Entity string `json:"entity"`
Rule string `json:"rule"`
}
type Seed struct {
ImportExcel bool `json:"import_excel,omitempty"`
MaxRows int `json:"max_rows,omitempty"`
}
func Parse(raw json.RawMessage) (*Blueprint, error) {
var bp Blueprint
if err := json.Unmarshal(raw, &bp); err != nil {
return nil, fmt.Errorf("invalid blueprint json: %w", err)
}
return &bp, nil
}
func (bp *Blueprint) Validate(pathSlug string) error {
if bp.Version != "1.0" {
return fmt.Errorf("unsupported version: %s", bp.Version)
}
bp.SanitizeIdentifiers()
// 连字符等非法字符规范为下划线schema/表名不能含 -
bp.Meta.Slug = NormalizeIdent(bp.Meta.Slug)
pathSlug = NormalizeIdent(pathSlug)
if err := checkIdent("meta.slug", bp.Meta.Slug); err != nil {
return err
}
if bp.Meta.Slug != pathSlug {
return fmt.Errorf("meta.slug(%s) != path slug(%s)", bp.Meta.Slug, pathSlug)
}
if bp.Meta.Name == "" {
return fmt.Errorf("meta.name required")
}
if bp.Storage.Mode != "schema_per_app" && bp.Storage.Mode != "database_per_app" {
return fmt.Errorf("unsupported storage.mode: %s", bp.Storage.Mode)
}
if bp.Storage.Engine != "postgres" && bp.Storage.Engine != "mysql" {
return fmt.Errorf("unsupported storage.engine: %s", bp.Storage.Engine)
}
if len(bp.Entities) == 0 {
return fmt.Errorf("entities required")
}
entityNames := map[string]Entity{}
for i, e := range bp.Entities {
prefix := fmt.Sprintf("entities[%d]", i)
if err := checkIdent(prefix+".name", e.Name); err != nil {
return err
}
if err := checkIdent(prefix+".table", e.Table); err != nil {
return err
}
if err := checkIdent(prefix+".primary_key", e.PrimaryKey); err != nil {
return err
}
if len(e.Fields) == 0 {
return fmt.Errorf("%s.fields required", prefix)
}
fields := map[string]struct{}{}
pkFound := false
for j, f := range e.Fields {
fp := fmt.Sprintf("%s.fields[%d]", prefix, j)
if err := checkIdent(fp+".name", f.Name); err != nil {
return err
}
if !validFieldType(f.Type) {
return fmt.Errorf("%s.type invalid: %s", fp, f.Type)
}
f.Type = NormalizeFieldType(f.Type)
if _, ok := fields[f.Name]; ok {
return fmt.Errorf("%s duplicate field %s", prefix, f.Name)
}
fields[f.Name] = struct{}{}
if f.Name == e.PrimaryKey {
pkFound = true
}
}
if !pkFound {
return fmt.Errorf("%s primary_key %s not in fields", prefix, e.PrimaryKey)
}
for j, idx := range e.Indexes {
ip := fmt.Sprintf("%s.indexes[%d]", prefix, j)
if err := checkIdent(ip+".name", idx.Name); err != nil {
return err
}
if !strings.HasPrefix(idx.Name, "idx_") {
return fmt.Errorf("%s.name must start with idx_", ip)
}
for _, col := range idx.Columns {
if _, ok := fields[col]; !ok {
return fmt.Errorf("%s unknown column %s", ip, col)
}
}
}
entityNames[e.Name] = e
}
if len(bp.Apis.Resources) == 0 {
return fmt.Errorf("apis.resources required")
}
for i, r := range bp.Apis.Resources {
prefix := fmt.Sprintf("apis.resources[%d]", i)
if _, ok := entityNames[r.Entity]; !ok {
return fmt.Errorf("%s unknown entity %s", prefix, r.Entity)
}
path := strings.TrimPrefix(r.Path, "/")
if err := checkIdent(prefix+".path", path); err != nil {
return err
}
if len(r.Operations) == 0 {
return fmt.Errorf("%s.operations required", prefix)
}
}
return nil
}
// AssignSchemaName 由平台重写 schemadatabase_per_app 时使用 public。
func (bp *Blueprint) AssignSchemaName(tenantID int64) string {
if bp.Storage.Mode == "database_per_app" {
bp.Storage.SchemaName = "public"
return "public"
}
name := fmt.Sprintf("app_t%d_%s", tenantID, bp.Meta.Slug)
if len(name) > 48 {
name = name[:48]
}
bp.Storage.SchemaName = name
return name
}
// AssignDatabaseName database_per_app 时返回独立库名,否则空。
func (bp *Blueprint) AssignDatabaseName(tenantID int64) string {
if bp.Storage.Mode != "database_per_app" {
return ""
}
name := fmt.Sprintf("appdb_t%d_%s", tenantID, bp.Meta.Slug)
if len(name) > 48 {
name = name[:48]
}
return name
}
func checkIdent(field, v string) error {
if !identRe.MatchString(v) {
return fmt.Errorf("%s invalid identifier: %s", field, v)
}
if _, bad := reserved[v]; bad {
return fmt.Errorf("%s reserved identifier: %s", field, v)
}
return nil
}
// SanitizeIdentifiers 将 camelCase / 点号等统一为 snake_case并同步页面与 API 引用。
func (bp *Blueprint) SanitizeIdentifiers() {
entityRename := map[string]string{}
for i := range bp.Entities {
e := &bp.Entities[i]
oldName := e.Name
e.Name = NormalizeIdent(e.Name)
e.Table = NormalizeIdent(e.Table)
e.PrimaryKey = NormalizeIdent(e.PrimaryKey)
if oldName != "" {
entityRename[oldName] = e.Name
entityRename[NormalizeIdent(oldName)] = e.Name
}
fieldRename := map[string]string{}
used := map[string]int{}
for j := range e.Fields {
f := &e.Fields[j]
old := f.Name
next := NormalizeIdent(old)
if n, ok := used[next]; ok {
used[next] = n + 1
next = fmt.Sprintf("%s_%d", next, n+1)
} else {
used[next] = 1
}
f.Name = next
f.Type = NormalizeFieldType(f.Type)
if old != "" {
fieldRename[old] = next
}
}
e.PrimaryKey = renameOrSelf(fieldRename, e.PrimaryKey)
for j := range e.Indexes {
idx := &e.Indexes[j]
idx.Name = NormalizeIdent(idx.Name)
if !strings.HasPrefix(idx.Name, "idx_") {
idx.Name = "idx_" + idx.Name
}
for k, col := range idx.Columns {
idx.Columns[k] = renameOrSelf(fieldRename, col)
}
}
// pages / apis that reference this entity's fields
for pi := range bp.Pages {
p := &bp.Pages[pi]
entRef := NormalizeIdent(p.Entity)
if p.Entity == oldName || p.Entity == e.Name || entRef == e.Name || entRef == NormalizeIdent(oldName) {
p.Entity = e.Name
if p.Layout != nil {
p.Layout.Columns = renameSlice(fieldRename, p.Layout.Columns)
p.Layout.Filters = renameSlice(fieldRename, p.Layout.Filters)
p.Layout.FormFields = renameSlice(fieldRename, p.Layout.FormFields)
for wi := range p.Layout.Widgets {
w := &p.Layout.Widgets[wi]
w.Metric = renameOrSelf(fieldRename, w.Metric)
w.GroupBy = renameOrSelf(fieldRename, w.GroupBy)
w.XField = renameOrSelf(fieldRename, w.XField)
w.LabelField = renameOrSelf(fieldRename, w.LabelField)
w.ValueField = renameOrSelf(fieldRename, w.ValueField)
w.WarnField = renameOrSelf(fieldRename, w.WarnField)
w.FilterField = renameOrSelf(fieldRename, w.FilterField)
w.Metrics = renameSlice(fieldRename, w.Metrics)
w.Columns = renameSlice(fieldRename, w.Columns)
if w.Entity == oldName || NormalizeIdent(w.Entity) == e.Name {
w.Entity = e.Name
}
}
}
}
}
for ri := range bp.Apis.Resources {
r := &bp.Apis.Resources[ri]
entRef := NormalizeIdent(r.Entity)
if r.Entity == oldName || r.Entity == e.Name || entRef == e.Name || entRef == NormalizeIdent(oldName) {
r.Entity = e.Name
if r.List != nil {
r.List.AllowedFilters = renameSlice(fieldRename, r.List.AllowedFilters)
r.List.AllowedSorts = renameSlice(fieldRename, r.List.AllowedSorts)
}
}
}
}
for i := range bp.Pages {
p := &bp.Pages[i]
p.Entity = renameOrSelf(entityRename, p.Entity)
}
for i := range bp.Apis.Resources {
r := &bp.Apis.Resources[i]
r.Entity = renameOrSelf(entityRename, r.Entity)
path := strings.TrimPrefix(r.Path, "/")
r.Path = "/" + NormalizeIdent(path)
}
for i := range bp.Security.RowPolicies {
rp := &bp.Security.RowPolicies[i]
rp.Entity = renameOrSelf(entityRename, rp.Entity)
}
}
func renameOrSelf(m map[string]string, v string) string {
if v == "" {
return v
}
if n, ok := m[v]; ok {
return n
}
return NormalizeIdent(v)
}
func renameSlice(m map[string]string, in []string) []string {
if len(in) == 0 {
return in
}
out := make([]string, len(in))
for i, v := range in {
out[i] = renameOrSelf(m, v)
}
return out
}
// NormalizeSlug 兼容旧名。
func NormalizeSlug(s string) string { return NormalizeIdent(s) }
// NormalizeIdent 驼峰/点号/连字符 → snake_case符合 ^[a-z][a-z0-9_]{1,47}$
func NormalizeIdent(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "field"
}
var b strings.Builder
b.Grow(len(s) + 8)
prevUnder := false
for i, r := range s {
switch {
case r >= 'A' && r <= 'Z':
if i > 0 && !prevUnder {
b.WriteByte('_')
}
b.WriteByte(byte(r - 'A' + 'a'))
prevUnder = false
case r >= 'a' && r <= 'z' || r >= '0' && r <= '9':
b.WriteByte(byte(r))
prevUnder = false
default:
// '.', '-', 空格等
if !prevUnder && b.Len() > 0 {
b.WriteByte('_')
prevUnder = true
}
}
}
s = strings.Trim(b.String(), "_")
s = multiUnderRe.ReplaceAllString(s, "_")
if s == "" {
return "field"
}
if s[0] < 'a' || s[0] > 'z' {
s = "f_" + s
}
if len(s) > 48 {
s = strings.TrimRight(s[:48], "_")
}
if s == "" {
return "field"
}
return s
}
// NormalizeFieldType 将常见别名收成蓝图允许的 type。
func NormalizeFieldType(t string) string {
t = strings.ToLower(strings.TrimSpace(t))
switch t {
case "float", "float32", "float64", "double", "number", "numeric", "real", "money":
return "decimal"
case "integer", "int32", "int64", "long", "serial":
if t == "int64" || t == "long" || t == "serial" {
return "bigint"
}
return "int"
case "bool":
return "boolean"
case "varchar", "str", "char":
return "string"
case "timestamp", "timestamptz", "time":
return "datetime"
case "file", "attachment", "blob":
return "file_ref"
case "string", "text", "int", "bigint", "decimal", "boolean", "date", "datetime", "enum", "json", "file_ref":
return t
default:
if t == "" {
return "string"
}
return t
}
}
func validFieldType(t string) bool {
switch NormalizeFieldType(t) {
case "string", "text", "int", "bigint", "decimal", "boolean", "date", "datetime", "enum", "json", "file_ref":
return true
default:
return false
}
}
func BoolOr(p *bool, def bool) bool {
if p == nil {
return def
}
return *p
}

View File

@@ -0,0 +1,52 @@
package blueprint
import "testing"
func TestNormalizeIdent(t *testing.T) {
cases := map[string]string{
"beforeDay": "before_day",
"cjl.value": "cjl_value",
"nextDay": "next_day",
"settlement-observation": "settlement_observation",
"Already_ok": "already_ok",
}
for in, want := range cases {
got := NormalizeIdent(in)
if got != want {
t.Fatalf("%s => %s, want %s", in, got, want)
}
}
}
func TestSanitizeFields(t *testing.T) {
bp := &Blueprint{
Version: "1.0",
Meta: Meta{Name: "t", Slug: "demo_app", Locale: "zh-CN"},
Storage: Storage{Mode: "schema_per_app", Engine: "postgres"},
Entities: []Entity{{
Name: "Point", Table: "Point", PrimaryKey: "id", Label: "p",
Fields: []Field{
{Name: "id", Label: "ID", Type: "bigint"},
{Name: "beforeDay", Label: "b", Type: "int"},
{Name: "cjl.value", Label: "v", Type: "decimal"},
},
}},
Apis: Apis{Resources: []APIResource{{
Entity: "Point", Path: "/Points", Operations: []string{"list"},
List: &ListOpt{AllowedFilters: []string{"beforeDay"}},
}}},
Pages: []Page{{
ID: "l", Title: "list", Route: "/x", Type: "list", Entity: "Point",
Layout: &PageLayout{Columns: []string{"beforeDay", "cjl.value"}, Filters: []string{"beforeDay"}},
}},
}
if err := bp.Validate("demo_app"); err != nil {
t.Fatal(err)
}
if bp.Entities[0].Fields[1].Name != "before_day" {
t.Fatalf("field=%s", bp.Entities[0].Fields[1].Name)
}
if bp.Pages[0].Layout.Columns[0] != "before_day" {
t.Fatalf("col=%v", bp.Pages[0].Layout.Columns)
}
}

View File

@@ -0,0 +1,163 @@
package blueprint
import (
"fmt"
"strings"
)
// MergeResult describes what was added when merging incoming into base.
type MergeResult struct {
AddedPages []string
AddedEntities []string
AddedResources []string
}
// MergeInto merges incoming pages/entities/apis into base (existing published app).
// Existing entities keep their fields; new fields on known entities are appended.
// Pages with the same id or route are rejected; new pages are appended.
func MergeInto(base, incoming *Blueprint) (*MergeResult, error) {
if base == nil {
return nil, fmt.Errorf("base blueprint is nil")
}
if incoming == nil {
return nil, fmt.Errorf("incoming blueprint is nil")
}
res := &MergeResult{}
entityByName := map[string]int{}
for i, e := range base.Entities {
entityByName[e.Name] = i
}
for _, e := range incoming.Entities {
if idx, ok := entityByName[e.Name]; ok {
base.Entities[idx] = mergeEntity(base.Entities[idx], e)
continue
}
base.Entities = append(base.Entities, e)
entityByName[e.Name] = len(base.Entities) - 1
res.AddedEntities = append(res.AddedEntities, e.Name)
}
pathKey := func(p string) string {
p = strings.TrimPrefix(strings.TrimSpace(p), "/")
return p
}
resByPath := map[string]int{}
for i, r := range base.Apis.Resources {
resByPath[pathKey(r.Path)] = i
}
for _, r := range incoming.Apis.Resources {
k := pathKey(r.Path)
if idx, ok := resByPath[k]; ok {
base.Apis.Resources[idx] = mergeResource(base.Apis.Resources[idx], r)
continue
}
base.Apis.Resources = append(base.Apis.Resources, r)
resByPath[k] = len(base.Apis.Resources) - 1
res.AddedResources = append(res.AddedResources, k)
}
pageByID := map[string]struct{}{}
routeBy := map[string]struct{}{}
for _, p := range base.Pages {
pageByID[p.ID] = struct{}{}
routeBy[p.Route] = struct{}{}
}
for _, p := range incoming.Pages {
if _, ok := pageByID[p.ID]; ok {
return nil, fmt.Errorf("page id already exists: %s (use a new page id when adding to an existing app)", p.ID)
}
if _, ok := routeBy[p.Route]; ok {
return nil, fmt.Errorf("page route already exists: %s", p.Route)
}
base.Pages = append(base.Pages, p)
pageByID[p.ID] = struct{}{}
routeBy[p.Route] = struct{}{}
res.AddedPages = append(res.AddedPages, p.ID)
}
if incoming.Meta.Description != "" && base.Meta.Description == "" {
base.Meta.Description = incoming.Meta.Description
}
if incoming.Meta.UIPreset != "" && base.Meta.UIPreset == "" {
base.Meta.UIPreset = incoming.Meta.UIPreset
}
if len(incoming.Meta.UI) > 0 && len(base.Meta.UI) == 0 {
base.Meta.UI = incoming.Meta.UI
}
if incoming.Seed != nil && base.Seed == nil {
base.Seed = incoming.Seed
}
if len(res.AddedPages) == 0 && len(res.AddedEntities) == 0 && len(res.AddedResources) == 0 {
return nil, fmt.Errorf("nothing new to publish: provide newly generated pages (and entities/apis if needed) for an existing app")
}
return res, nil
}
func mergeEntity(base, incoming Entity) Entity {
fieldBy := map[string]struct{}{}
for _, f := range base.Fields {
fieldBy[f.Name] = struct{}{}
}
for _, f := range incoming.Fields {
if _, ok := fieldBy[f.Name]; ok {
continue
}
base.Fields = append(base.Fields, f)
fieldBy[f.Name] = struct{}{}
}
idxBy := map[string]struct{}{}
for _, ix := range base.Indexes {
idxBy[ix.Name] = struct{}{}
}
for _, ix := range incoming.Indexes {
if _, ok := idxBy[ix.Name]; ok {
continue
}
base.Indexes = append(base.Indexes, ix)
}
if base.Label == "" && incoming.Label != "" {
base.Label = incoming.Label
}
return base
}
func mergeResource(base, incoming APIResource) APIResource {
opSet := map[string]struct{}{}
for _, op := range base.Operations {
opSet[op] = struct{}{}
}
for _, op := range incoming.Operations {
if _, ok := opSet[op]; ok {
continue
}
base.Operations = append(base.Operations, op)
opSet[op] = struct{}{}
}
if base.List == nil && incoming.List != nil {
base.List = incoming.List
} else if base.List != nil && incoming.List != nil {
filt := map[string]struct{}{}
for _, f := range base.List.AllowedFilters {
filt[f] = struct{}{}
}
for _, f := range incoming.List.AllowedFilters {
if _, ok := filt[f]; !ok {
base.List.AllowedFilters = append(base.List.AllowedFilters, f)
filt[f] = struct{}{}
}
}
sorts := map[string]struct{}{}
for _, s := range base.List.AllowedSorts {
sorts[s] = struct{}{}
}
for _, s := range incoming.List.AllowedSorts {
if _, ok := sorts[s]; !ok {
base.List.AllowedSorts = append(base.List.AllowedSorts, s)
sorts[s] = struct{}{}
}
}
}
return base
}

View File

@@ -0,0 +1,53 @@
package blueprint
import "testing"
func TestMergeIntoAddsPages(t *testing.T) {
base := &Blueprint{
Version: "1.0",
Meta: Meta{Name: "app", Slug: "demo"},
Entities: []Entity{{
Name: "item", Table: "item", Label: "Item", PrimaryKey: "id",
Fields: []Field{{Name: "id", Label: "ID", Type: "string"}},
}},
Apis: Apis{
BasePath: "/api/v1/apps/demo",
Resources: []APIResource{{
Entity: "item", Path: "/items", Operations: []string{"list"},
}},
},
Pages: []Page{{ID: "item_list", Title: "列表", Route: "/items", Type: "list", Entity: "item"}},
}
incoming := &Blueprint{
Entities: []Entity{{
Name: "order", Table: "order", Label: "Order", PrimaryKey: "id",
Fields: []Field{{Name: "id", Label: "ID", Type: "string"}},
}},
Apis: Apis{Resources: []APIResource{{
Entity: "order", Path: "/orders", Operations: []string{"list", "import"},
}}},
Pages: []Page{{ID: "order_list", Title: "订单", Route: "/orders", Type: "list", Entity: "order"}},
}
res, err := MergeInto(base, incoming)
if err != nil {
t.Fatal(err)
}
if len(res.AddedPages) != 1 || res.AddedPages[0] != "order_list" {
t.Fatalf("added pages: %+v", res.AddedPages)
}
if len(base.Pages) != 2 || len(base.Entities) != 2 {
t.Fatalf("pages=%d entities=%d", len(base.Pages), len(base.Entities))
}
}
func TestMergeIntoRejectsDuplicatePageID(t *testing.T) {
base := &Blueprint{
Pages: []Page{{ID: "a", Route: "/a", Type: "list", Entity: "x"}},
}
incoming := &Blueprint{
Pages: []Page{{ID: "a", Route: "/b", Type: "list", Entity: "x"}},
}
if _, err := MergeInto(base, incoming); err == nil {
t.Fatal("expected duplicate page id error")
}
}