Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions runtime/lua/modules/registry/changes.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ func changesUpdate(l *lua.LState) int {
return 2
}

// An update that says nothing about root status inherits the stored one.
// Absence means "unchanged" here, not "false": a writer unaware of the
// field would otherwise demote a deployment root on every rewrite, and a
// demoted root loses its parameters at the next boot's link stage.
if entryTable.RawGetString("root") == lua.LNil && changes.snapshot != nil {
if stored, storedErr := changes.snapshot.GetEntry(entry.ID); storedErr == nil {
entry.DependencyRoot = stored.DependencyRoot
}
}

changes.ops = append(changes.ops, regapi.Operation{
Kind: regapi.EntryUpdate,
Entry: entry,
Expand Down
77 changes: 77 additions & 0 deletions runtime/lua/modules/registry/changes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

lua "github.com/wippyai/go-lua"
"github.com/wippyai/runtime/api/attrs"
regapi "github.com/wippyai/runtime/api/registry"
"go.uber.org/zap"
)
Expand Down Expand Up @@ -80,3 +81,79 @@ func TestChangesToStringEmpty(t *testing.T) {
t.Errorf("expected %s, got %s", expected, str)
}
}

// A writer unaware of root status must not demote a deployment root. This is
// the shape keeper takes on every dependency update: read the entry, change a
// field, write it back. Absence of root on an update means unchanged.
func TestChangesUpdatePreservesStoredRoot(t *testing.T) {
l := newTestState()
defer l.Close()

stored := regapi.Entry{
ID: regapi.ParseID("app.deps:keeper"),
Kind: "ns.dependency",
Meta: attrs.Bag{"module": "kickside/kickside"},
DependencyRoot: true,
}

changes := &Changes{
snapshot: &Snapshot{entries: []regapi.Entry{stored}, log: zap.NewNop()},
ops: []regapi.Operation{},
log: zap.NewNop(),
}

ud := l.NewUserData()
ud.Value = changes
l.Push(ud)

entryTable := l.CreateTable(0, 3)
entryTable.RawSetString("id", lua.LString("app.deps:keeper"))
entryTable.RawSetString("kind", lua.LString("ns.dependency"))
entryTable.RawSetString("meta", l.CreateTable(0, 0))
l.Push(entryTable)

changesUpdate(l)

if len(changes.ops) != 1 {
t.Fatalf("expected one op, got %d", len(changes.ops))
}
if !changes.ops[0].Entry.DependencyRoot {
t.Error("expected an update that omits root to inherit the stored root status")
}
}

func TestChangesUpdateHonoursExplicitDemotion(t *testing.T) {
l := newTestState()
defer l.Close()

stored := regapi.Entry{
ID: regapi.ParseID("app.deps:keeper"),
Kind: "ns.dependency",
DependencyRoot: true,
}

changes := &Changes{
snapshot: &Snapshot{entries: []regapi.Entry{stored}, log: zap.NewNop()},
ops: []regapi.Operation{},
log: zap.NewNop(),
}

ud := l.NewUserData()
ud.Value = changes
l.Push(ud)

entryTable := l.CreateTable(0, 3)
entryTable.RawSetString("id", lua.LString("app.deps:keeper"))
entryTable.RawSetString("kind", lua.LString("ns.dependency"))
entryTable.RawSetString("root", lua.LFalse)
l.Push(entryTable)

changesUpdate(l)

if len(changes.ops) != 1 {
t.Fatalf("expected one op, got %d", len(changes.ops))
}
if changes.ops[0].Entry.DependencyRoot {
t.Error("expected an explicit root=false to demote the entry")
}
}
7 changes: 6 additions & 1 deletion runtime/lua/modules/registry/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,14 @@ Retrieves a single entry by ID from the current registry state.

**Returns:**

- Success: Entry table with fields `{id: string, kind: string, meta: table, data: any}`, nil
- Success: Entry table with fields `{id: string, kind: string, meta: table, data: any, root: boolean}`, nil
- Error: nil, structured error

`root` marks an `ns.dependency` selected as a deployment root. It is the sole
authority for that status — `meta` is user space and carries no trust. Reads
always return it; writes may omit it, and omitting it on an update demotes the
entry, so carry it back on any read-modify-write.

**Errors (structured):**

| Condition | Kind | Retryable |
Expand Down
5 changes: 4 additions & 1 deletion runtime/lua/modules/registry/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ var idType = typ.NewRecord().
Field("name", typ.String).
Build()

// Entry type represents a registry entry
// Entry type represents a registry entry. root marks an ns.dependency selected
// as a deployment root. It is optional on the way in and always present on the
// way out, so a writer may omit it while a reader can always carry it back.
var entryType = typ.NewRecord().
Field("id", typ.String).
Field("kind", typ.String).
Field("meta", typ.NewMap(typ.String, typ.Any)).
Field("data", typ.Any).
OptField("root", typ.Boolean).
Build()

// Forward declarations for self-referential types
Expand Down
11 changes: 11 additions & 0 deletions runtime/lua/modules/registry/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ func luaTableToEntry(l *lua.LState, table *lua.LTable) (regapi.Entry, error) {
entry.Meta = attrs.Bag{}
}

// Extract deployment-root status. The field is the sole authority for it:
// meta is user space and carries no trust, so a root that loses the flag
// here is demoted with nothing left to recover it from.
if rootVal, ok := table.RawGetString("root").(lua.LBool); ok {
entry.DependencyRoot = bool(rootVal)
}

// Extract data
dataVal := table.RawGetString("data")
if dataVal != lua.LNil {
Expand All @@ -79,6 +86,10 @@ func entryToLuaTable(l *lua.LState, entry regapi.Entry) (*lua.LTable, error) {
// Add kind
entryTable.RawSetString("kind", lua.LString(entry.Kind))

// Emit deployment-root status so a read-modify-write from Lua carries it
// back instead of demoting the entry.
entryTable.RawSetString("root", lua.LBool(entry.DependencyRoot))

// Convert metadata
metaTable := l.CreateTable(0, len(entry.Meta))
for k, v := range entry.Meta {
Expand Down
72 changes: 72 additions & 0 deletions runtime/lua/modules/registry/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ package registry

import (
"testing"

lua "github.com/wippyai/go-lua"
"github.com/wippyai/runtime/api/attrs"
regapi "github.com/wippyai/runtime/api/registry"
)

func TestMapsEqualNested(t *testing.T) {
Expand Down Expand Up @@ -218,3 +222,71 @@ func TestValuesEqualArrayVsNonArray(t *testing.T) {
t.Error("expected array and non-array to be unequal")
}
}

// root marks an ns.dependency selected as a deployment root and is the sole
// authority for that status: meta is user space and carries no trust. A Lua
// write path that cannot read or emit the field silently demotes every root.
func TestLuaTableToEntryReadsRoot(t *testing.T) {
l := newTestState()
defer l.Close()

table := l.CreateTable(0, 3)
table.RawSetString("id", lua.LString("app.deps:crm"))
table.RawSetString("kind", lua.LString("ns.dependency"))
table.RawSetString("root", lua.LTrue)

entry, err := luaTableToEntry(l, table)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !entry.DependencyRoot {
t.Error("expected root to be carried onto the entry")
}
}

func TestLuaTableToEntryDefaultsRootFalse(t *testing.T) {
l := newTestState()
defer l.Close()

table := l.CreateTable(0, 2)
table.RawSetString("id", lua.LString("app.deps:crm"))
table.RawSetString("kind", lua.LString("ns.dependency"))

entry, err := luaTableToEntry(l, table)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if entry.DependencyRoot {
t.Error("expected root to default to false when absent")
}
}

// Read-modify-write from Lua must not demote a root, so the flag has to survive
// both directions of the conversion.
func TestEntryLuaRoundTripPreservesRoot(t *testing.T) {
l := newTestState()
defer l.Close()

original := regapi.Entry{
ID: regapi.ParseID("app.deps:crm"),
Kind: "ns.dependency",
Meta: attrs.Bag{"module": "kickside/app"},
DependencyRoot: true,
}

table, err := entryToLuaTable(l, original)
if err != nil {
t.Fatalf("unexpected error converting entry to table: %v", err)
}
if table.RawGetString("root") != lua.LTrue {
t.Fatal("expected root to be emitted onto the Lua table")
}

back, err := luaTableToEntry(l, table)
if err != nil {
t.Fatalf("unexpected error converting table to entry: %v", err)
}
if !back.DependencyRoot {
t.Error("expected root to survive a Lua round trip")
}
}
14 changes: 14 additions & 0 deletions tests/app/src/test/registry/_index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@ entries:
- registry
- funcs

- name: dependency_root_field
kind: function.lua
meta:
type: test
suite: registry
order: 82
description: root survives a Lua write and a read-modify-write
source: file://dependency_root_field.lua
method: main
imports:
assert2: app.lib:assert
modules:
- registry

- name: restore_dependency_delete_order
kind: function.lua
meta:
Expand Down
74 changes: 74 additions & 0 deletions tests/app/src/test/registry/dependency_root_field.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
-- SPDX-License-Identifier: MPL-2.0

local assert = require("assert2")
local registry = require("registry")

-- root marks an ns.dependency selected as a deployment root and is the sole
-- authority for that status: meta is user space and carries no trust. A writer
-- that cannot carry the field silently demotes every root it touches, and a
-- reader that cannot see it demotes the entry again on the next rewrite.
local function entry_for(id, root)
return {
id = id,
kind = "function.lua",
root = root,
meta = {
comment = "dependency root transport regression",
module = "wippy/example",
},
data = {
source = "return { main = function() return true end }",
method = "main",
},
}
end

local function main()
local original_version, version_err = registry.current_version()
assert.is_nil(version_err, "current version no error")
assert.not_nil(original_version, "have original version")

local root_id = "app.test.registry:dependency_root_marked"
local plain_id = "app.test.registry:dependency_root_unmarked"

local snap, snap_err = registry.snapshot()
assert.is_nil(snap_err, "snapshot no error")
local changes = snap:changes()
changes:create(entry_for(root_id, true))
changes:create(entry_for(plain_id, false))
local applied_version, apply_err = changes:apply()
assert.is_nil(apply_err, "apply changeset")
assert.not_nil(applied_version, "applied version returned")

local marked, marked_err = registry.get(root_id)
assert.is_nil(marked_err, "read marked entry")
assert.not_nil(marked, "marked entry exists")
assert.eq(marked.root, true, "root survives the write and the read")

local unmarked, unmarked_err = registry.get(plain_id)
assert.is_nil(unmarked_err, "read unmarked entry")
assert.not_nil(unmarked, "unmarked entry exists")
assert.eq(unmarked.root, false, "an unmarked entry stays unmarked")

-- A read-modify-write is the path keeper takes on every dependency update.
local snap2, snap2_err = registry.snapshot()
assert.is_nil(snap2_err, "second snapshot no error")
local rewrite = snap2:changes()
marked.meta.comment = "rewritten"
rewrite:update(marked)
local rewritten_version, rewrite_err = rewrite:apply()
assert.is_nil(rewrite_err, "apply rewrite")
assert.not_nil(rewritten_version, "rewritten version returned")

local after, after_err = registry.get(root_id)
assert.is_nil(after_err, "read rewritten entry")
assert.eq(after.root, true, "round trip through Lua does not demote a root")

local restored, restore_err = registry.apply_version(original_version)
assert.is_nil(restore_err, "restore original version")
assert.ok(restored, "restore original succeeded")

return true
end

return { main = main }