feat: establish Orbit models, migrations and development baseline

This commit is contained in:
2026-09-17 07:04:21 +08:00
commit 4f2935e36f
84 changed files with 4915 additions and 0 deletions

16
model/digest.go Normal file
View File

@@ -0,0 +1,16 @@
package model
import (
"database/sql/driver"
"errors"
)
func (d Digest) Value() (driver.Value, error) { return d[:], nil }
func (d *Digest) Scan(src any) error {
b, ok := src.([]byte)
if !ok || len(b) != len(d) {
return errors.New("digest: expected BINARY(32)")
}
copy(d[:], b)
return nil
}

65
model/id.go Normal file
View File

@@ -0,0 +1,65 @@
// Package model 提供 Orbit 各模块共用且不绑定存储实现的基础类型。
// 服务端模型不直接作为公开 SDK 协议。
package model
import (
"crypto/rand"
"database/sql/driver"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
)
// ID 是 128 位随机标识,对外使用小写十六进制,MySQL 使用 BINARY(16)。
// 标识只用于定位资源,不能作为访问凭据。
type ID string
func NewID() (ID, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return ID(hex.EncodeToString(b[:])), nil
}
func (id ID) Validate() error {
if len(id) != 32 {
return errors.New("id must contain 32 lowercase hexadecimal characters")
}
for _, c := range id {
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
return errors.New("id must contain lowercase hexadecimal characters")
}
}
return nil
}
func (id ID) Value() (driver.Value, error) {
if err := id.Validate(); err != nil {
return nil, err
}
return hex.DecodeString(string(id))
}
func (id *ID) Scan(src any) error {
b, ok := src.([]byte)
if !ok || len(b) != 16 {
return fmt.Errorf("id: expected BINARY(16), got %T", src)
}
*id = ID(hex.EncodeToString(b))
return nil
}
func (id *ID) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
candidate := ID(s)
if err := candidate.Validate(); err != nil {
return err
}
*id = candidate
return nil
}

58
model/json.go Normal file
View File

@@ -0,0 +1,58 @@
package model
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
)
// JSON 是经过合法性检查的通用文档。输入内容应使用版本化结构或业务侧引用,
// Orbit 不解释行业专用字段。
type JSON json.RawMessage
func (j JSON) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
if !json.Valid(j) {
return nil, errors.New("invalid JSON document")
}
return []byte(j), nil
}
func (j *JSON) Scan(src any) error {
if src == nil {
*j = nil
return nil
}
var b []byte
switch v := src.(type) {
case []byte:
b = v
case string:
b = []byte(v)
default:
return fmt.Errorf("JSON: unsupported source %T", src)
}
if !json.Valid(b) {
return errors.New("invalid JSON document")
}
*j = append((*j)[:0], b...)
return nil
}
func (j JSON) MarshalJSON() ([]byte, error) {
if j == nil {
return []byte("null"), nil
}
if !json.Valid(j) {
return nil, errors.New("invalid JSON document")
}
return []byte(j), nil
}
func (j *JSON) UnmarshalJSON(b []byte) error {
if !json.Valid(b) {
return errors.New("invalid JSON document")
}
*j = append((*j)[:0], b...)
return nil
}

51
model/primitives_test.go Normal file
View File

@@ -0,0 +1,51 @@
package model
import (
"encoding/json"
"testing"
)
func TestIDBinaryAndJSONRoundTrip(t *testing.T) {
id, err := NewID()
if err != nil {
t.Fatal(err)
}
binary, err := id.Value()
if err != nil {
t.Fatal(err)
}
var copy ID
if err := copy.Scan(binary); err != nil || copy != id {
t.Fatalf("binary roundtrip: %s %v", copy, err)
}
b, err := json.Marshal(id)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(b, &copy); err != nil || copy != id {
t.Fatalf("JSON roundtrip: %s %v", copy, err)
}
if err := json.Unmarshal([]byte(`"0123"`), &copy); err == nil {
t.Fatal("accepted truncated ID")
}
if _, err := ID("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Value(); err == nil {
t.Fatal("accepted case-folded ID")
}
if err := copy.Scan([]byte{1}); err == nil {
t.Fatal("accepted truncated binary ID")
}
}
func TestJSONScanOwnsBytes(t *testing.T) {
src := []byte(`{"ok":true}`)
var doc JSON
if err := doc.Scan(src); err != nil {
t.Fatal(err)
}
src[2] = 'x'
if string(doc) != `{"ok":true}` {
t.Fatal("document aliases driver buffer")
}
if _, err := JSON(`{broken`).Value(); err == nil {
t.Fatal("accepted invalid JSON")
}
}

21
model/record.go Normal file
View File

@@ -0,0 +1,21 @@
package model
import "time"
// Record 是命名空间内实体的公共记录。Revision 从 1 开始,通过条件更新控制并发。
// 时间戳只用于追踪,不代替状态版本和租约校验。
type Record struct {
// NamespaceID 资源所属命名空间,参与主键、授权与关联校验。
NamespaceID ID `db:"namespace_id"`
// ID 记录标识;与命名空间一起定位运行资源。
ID ID `db:"id"`
// Revision 乐观并发版本,从 1 开始;更新时必须核对预期版本。
Revision uint64 `db:"revision"`
// CreatedAt 创建时间,统一 UTC,数据库精度为微秒。
CreatedAt time.Time `db:"created_at"`
// UpdatedAt 最后写入时间,统一 UTC;不能代替并发版本。
UpdatedAt time.Time `db:"updated_at"`
}
// Digest 保存 32 字节 SHA-256 摘要。凭据或执行令牌原文不得进入持久化模型、审计和日志。
type Digest [32]byte