6 Commits
v0.1.2 ... main

Author SHA1 Message Date
2c48c75387 fix: use FindDataRoot in FindOrInitSQLiteStore for consistent database location
Some checks failed
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Has been cancelled
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Has been cancelled
Build and Push Docker Container / build-and-push (push) Successful in 17m44s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Failing after 58s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Failing after 1m0s
2026-04-02 14:26:13 +02:00
e04a44cdcf remove line count from readme 2026-04-02 13:41:47 +02:00
b6c8a158af docs: simplify README and extract full reference to USAGE.md
- Condensed README to quick overview (~40 lines) with key features
- Added install instructions for Alpine, Arch, source, and Docker
- Created USAGE.md with full command reference, server mode, OIDC, and permission model docs
- New features documented: server mode, OIDC authentication, per-node permissions, multiplatform auto-builds

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:38:38 +02:00
5f0f8f3396 fix: update Dockerfile Go version to 1.25 and disable toolchain auto-download
All checks were successful
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 42s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 48s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 1m4s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 1m6s
Build and Push Docker Container / build-and-push (push) Successful in 17m50s
- Bump golang:1.24-alpine to golang:1.25-alpine to match go.mod requirement
- Set GOTOOLCHAIN=local to prevent segfault from toolchain download in Docker

This fixes the multiplatform build failure where go mod download would
attempt to download Go 1.25.0 in the container and crash.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:25:19 +02:00
24fb3a8b62 fix: prevent segfault in login command when session doesn't exist
LoadSession() was returning nil when the session file didn't exist (first login).
The login command then tried to dereference nil when setting session.Token,
causing a panic. Now LoadSession() returns an empty Session with the path set,
so callers can always use the returned session.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:23:34 +02:00
89432e608b feat: replace in_namespace relation with ownership-based namespace membership
Remove the in_namespace edge relation. A node now belongs to a namespace if that
namespace has has_ownership on it. This simplifies the model: namespace membership
is determined by the ownership chain rather than a separate relation type.

Changes:
- Remove RelInNamespace constant
- Add Namespace fields to AddInput, UpdateInput, and ListFilter
- Update Add() to resolve namespace from input and assign it as owner
- Update List() to filter by namespace ownership instead of in_namespace edges
- Update() can now transfer nodes between namespaces via ownership transfer
- Remove in_namespace self-references from ensureNamespace/ensureGlobalNamespace

The ownership chain now fully describes both permissions and namespace membership,
reducing redundancy. All tests pass with the new model.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:20:03 +02:00
17 changed files with 329 additions and 374 deletions

View File

@@ -1,8 +1,8 @@
FROM golang:1.24-alpine AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /app
ENV GOTOOLCHAIN=auto
ENV GOTOOLCHAIN=local
COPY src/go.mod src/go.sum ./
RUN go mod download

280
README.md
View File

@@ -1,266 +1,52 @@
# Axolotl
CLI-native lightweight issue tracker for you and your agents. A SQLite-based
single portable binary, built from ~1300 lines of Go code.
CLI-native issue tracker for you and your agents. Single binary, SQLite-backed, a few lines of Go.
## Features
## Install
- **Issues with dependencies** - blocks, subtask, related relations
- **Tagging system** - flexible tags with `_key::value` property pattern
- **Namespacing** - organize issues by project or team
- **Due dates** - track deadlines
- **Thread-safe** - WAL mode for concurrent access
- **Multiuser support** - @mentions and assignments, inbox per user
- **JSON output** - all commands support `--json` for agent integration
- **Alias system** - define custom command shortcuts with argument expansion
- **Single binary** - no dependencies, portable `.ax.db` file
**Alpine Linux** (apk):
Download from the Gitea package registry. Have a look
[here](https://g.eliaskohout.de/eliaskohout/-/packages/alpine/axolotl/).
## Installation
**Arch Linux** (pacman):
Download from the Gitea package registry. Have a look
[here](https://g.eliaskohout.de/eliaskohout/-/packages/arch/axolotl/).
**From source:**
```bash
go build -o ax .
go build -o ax ./src
```
**Docker (server mode):**
```bash
docker run -v ./data:/data g.eliaskohout.de/eliaskohout/axolotl-server:latest
```
Packages are built automatically on every version tag for `linux/amd64` and `linux/arm64`.
## Quick Start
```bash
# Initialize a new database
ax init .
# Create an issue
ax add "Implement feature X" --tag backend --prio high
# Create with relations
ax add "Fix bug in auth" --rel blocks:abc12
# List open issues
ax list --status open
# Show issue details
ax show abc12
# Update an issue
ax update abc12 --status done
# View your inbox
ax inbox
# Define an alias
ax alias mywork "list --namespace myproject --status open" --desc "My project tasks"
ax init . # create .ax.db in current dir
ax add "Fix login bug" --prio high # create an issue
ax list --status open # list open issues
ax show abc12 # show issue details
ax update abc12 --status done # close issue
ax inbox # your @mention inbox
```
## Commands
## Key Features
### `ax init [path]`
- **Graph relations** — `blocks`, `subtask`, `related`, `assignee`
- **Namespaces** — organize issues by project or team
- **Permissions** — per-node access control (`can_read`, `can_write`, `has_ownership`)
- **Aliases** — custom shortcuts with `$me`, `$1`, `$@` expansion
- **JSON output** — `--json` flag on all commands for agent integration
- **Multiuser** — `@mention` auto-creates inbox entries; `AX_USER` to switch users
- **Server mode** — HTTP JSON API with optional OIDC authentication (`ax serve` / `ax login`)
- **Portable** — single `.ax.db` file, no server required
Create a new `.ax.db` database in the specified directory (default: current).
### `ax add <title> [flags]`
Create a new node.
| Flag | Description |
|------|-------------|
| `--type` | Node type: `issue` (default), `note`, `user`, `namespace` |
| `--status` | Status: `open` (default), `done` |
| `--prio` | Priority: `high`, `medium`, `low` |
| `--namespace` | Namespace (default: current user) |
| `--tag` | Add tag (repeatable) |
| `--due` | Due date |
| `--content` | Content/body text |
| `--rel` | Add relation `type:id` (repeatable) |
### `ax update <id> [flags]`
Update a node.
| Flag | Description |
|------|-------------|
| `--title` | New title |
| `--status` | New status |
| `--prio` | New priority |
| `--type` | New type |
| `--namespace` | New namespace |
| `--assignee` | New assignee |
| `--due` | New due date |
| `--clear-due` | Clear due date |
| `--content` | New content |
| `--tag` | Add tag (repeatable) |
| `--tag-remove` | Remove tag (repeatable) |
| `--rel` | Add relation `type:id` (repeatable) |
| `--rel-remove` | Remove relation `type:id` (repeatable) |
### `ax show <id>`
Display node details.
### `ax list [flags]`
Query and list nodes.
| Flag | Description |
|------|-------------|
| `--type` | Filter by type |
| `--status` | Filter by status |
| `--prio` | Filter by priority |
| `--namespace` | Filter by namespace |
| `--tag` | Filter by tag (repeatable) |
| `--assignee` | Filter by assignee |
| `--mention` | Filter by mention |
### `ax edit <id>`
Open node content in `$EDITOR`.
### `ax del <id> [-f|--force]`
Delete a node. Prompts for confirmation unless `--force`.
### `ax alias [name] [command] [flags]`
Manage aliases.
```bash
ax alias # list all aliases
ax alias mywork "list --tag work" # create alias
ax alias mywork # show alias command
ax alias mywork "list --tag work2" # update alias
ax alias del mywork # delete alias
```
**Default aliases:**
| Alias | Command | Description |
|-------|---------|-------------|
| `mine` | `list --assignee $me --type issue --status open` | Show open issues assigned to you |
| `due` | `list --type issue --status open` | Show open issues |
| `inbox` | `list --mention $me` | Show your inbox |
**Alias argument expansion:**
| Variable | Expands to |
|----------|------------|
| `$me` | Current username |
| `$@` | All arguments |
| `$1`, `$2`, ... | Positional arguments |
```bash
# Create alias with argument expansion
ax alias find "list --tag $1 --status $2"
ax find backend open # expands to: list --tag backend --status open
```
## Relations
Relations connect nodes together:
| Type | Meaning | Behavior |
|------|---------|----------|
| `blocks` | A blocks B — B can't close until A is done | Enforced on status=done |
| `subtask` | A is a subtask of B | |
| `related` | A is related to B | |
| `assignee` | A is assigned to user | Single-value; set via `--assignee` flag |
| `in_namespace` | A belongs to namespace | Single-value; set via `--namespace` flag |
```bash
# Block an issue (B can't close until A is done)
ax update A --rel blocks:B
# Assign to user
ax update abc12 --assignee alice
# Create subtask
ax update abc12 --rel subtask:parent12
```
## Tags and Properties
Tags are flexible labels. Tags with pattern `_key::value` are properties:
```bash
# Regular tag
ax add "Task" --tag backend
# Property tags (set via flags)
ax add "Task" --type issue --status open --prio high
# Equivalent to: --tag _type::issue --tag _status::open --tag _prio::high
```
**Built-in properties:**
| Property | Values | Required |
|----------|--------|----------|
| `_type` | `issue`, `note`, `user`, `namespace` | Yes (default: `issue`) |
| `_status` | `open`, `done` | No |
| `_prio` | `high`, `medium`, `low` | No |
## Mentions and Inbox
Use `@username` in title or content to automatically add to user's inbox:
```bash
ax add "Review PR @alice" --content "@bob please check"
# Both alice and bob get this in their inbox
```
View inbox:
```bash
ax inbox # your inbox
AX_USER=alice ax inbox # alice's inbox
```
## JSON Output
All commands support `--json` for machine-readable output:
```bash
ax list --status open --json
ax show abc12 --json
```
Example output:
```json
{
"id": "abc12",
"title": "Implement feature",
"content": "Description here",
"created_at": "2026-03-25T10:00:00Z",
"updated_at": "2026-03-25T10:00:00Z",
"tags": ["_type::issue", "_status::open", "backend"],
"relations": {
"blocks": ["def34"]
}
}
```
## Configuration
`ax` stores user configuration in a JSON file. It searches for `.axconfig` in the
current directory and parent directories (like git finds `.git`), falling back to
`~/.config/ax/config.json`.
**Config file format:**
```json
{
"user": "alice",
"aliases": [
{"name": "mywork", "command": "list --namespace myproject", "description": "My tasks"}
]
}
```
## Database Location
`ax` searches for `.ax.db` in the current directory and parent directories,
similar to how git finds `.git`. This allows you to run commands from any
subdirectory.
## Environment Variables
| Variable | Description |
|----------|-------------|
| `AX_USER` | Override current username |
| `EDITOR` | Editor for `ax edit` (default: `vi`) |
For full command reference and examples, see [USAGE.md](USAGE.md).
## License

194
USAGE.md Normal file
View File

@@ -0,0 +1,194 @@
# Axolotl Usage Reference
## Commands
### `ax init [path]`
Create a new `.ax.db` in the specified directory (default: current).
### `ax add <title> [flags]`
| Flag | Description |
|------|-------------|
| `--type` | `issue` (default), `note`, `user`, `namespace` |
| `--status` | `open` (default), `done` |
| `--prio` | `high`, `medium`, `low` |
| `--namespace` | Namespace (default: current user) |
| `--tag` | Add tag (repeatable) |
| `--due` | Due date |
| `--content` | Body text |
| `--rel` | Relation `type:id` (repeatable) |
### `ax update <id> [flags]`
| Flag | Description |
|------|-------------|
| `--title` | New title |
| `--status` | New status |
| `--prio` | New priority |
| `--type` | New type |
| `--namespace` | Transfer to namespace |
| `--assignee` | Assign to user |
| `--due` / `--clear-due` | Set or clear due date |
| `--content` | New body text |
| `--tag` / `--tag-remove` | Add or remove tag |
| `--rel` / `--rel-remove` | Add or remove relation `type:id` |
### `ax show <id>`
Display node details.
### `ax list [flags]`
| Flag | Description |
|------|-------------|
| `--type` | Filter by type |
| `--status` | Filter by status |
| `--prio` | Filter by priority |
| `--namespace` | Filter by namespace |
| `--tag` | Filter by tag (repeatable) |
| `--assignee` | Filter by assignee |
| `--mention` | Filter by mention |
### `ax edit <id>`
Open node content in `$EDITOR`.
### `ax del <id> [-f]`
Delete a node. Prompts for confirmation unless `--force`.
### `ax alias [name] [command]`
```bash
ax alias # list all aliases
ax alias mywork "list --tag work" # create/update alias
ax alias del mywork # delete alias
```
**Built-in aliases:** `mine`, `due`, `inbox`
**Argument expansion:** `$me` → current user, `$@` → all args, `$1`/`$2`/… → positional
## Relations
| Type | Description |
|------|-------------|
| `blocks` | Prevents target from closing until this is done |
| `subtask` | Marks as subtask of target |
| `related` | General association |
| `assignee` | Assigns to a user (single-value) |
```bash
ax update A --rel blocks:B # A blocks B
ax update abc12 --assignee alice # assign to alice
```
## Tags and Properties
Tags follow the `_key::value` pattern for properties:
| Property | Values |
|----------|--------|
| `_type` | `issue`, `note`, `user`, `namespace` |
| `_status` | `open`, `done` |
| `_prio` | `high`, `medium`, `low` |
## Mentions and Inbox
Use `@username` in title or content to add to a user's inbox:
```bash
ax add "Review PR @alice" # alice gets an inbox entry
ax inbox # your inbox
AX_USER=alice ax inbox # alice's inbox
```
## JSON Output
All commands support `--json`:
```bash
ax list --status open --json
ax show abc12 --json
```
## Configuration
`ax` searches upward from CWD for `.axconfig`, falling back to `~/.config/ax/config.json`.
```json
{
"user": "alice",
"aliases": [
{"name": "mywork", "command": "list --namespace myproject", "description": "My tasks"}
]
}
```
## Server Mode
`ax` can run as a shared HTTP JSON API server:
```bash
ax serve # starts server on configured host:port (default: 0.0.0.0:7000)
```
The server exposes the same operations (add, list, show, update, delete) over HTTP. Clients connect by setting `remote.host` / `remote.port` in their config — the CLI then transparently routes calls to the server instead of a local database.
### OIDC Authentication
The server supports OIDC for authentication. Configure in `.axconfig`:
```json
{
"serve": { "host": "0.0.0.0", "port": 7000 },
"oidc": {
"issuer": "https://your-idp.example.com",
"client_id": "axolotl",
"client_secret": "secret",
"public_url": "https://ax.example.com",
"user_claim": "preferred_username"
}
}
```
Client login:
```bash
ax login # opens browser for OIDC flow, saves session token
```
Without OIDC configured, the server accepts an `X-Ax-User` header for the username (development/trusted networks only).
### Docker
```bash
docker run -v ./data:/data g.eliaskohout.de/eliaskohout/axolotl-server:latest
```
The image runs `ax serve` and exposes port 7000. Mount a volume at `/data` to persist the database.
## Permission Model
Every node has per-node access control. Permissions are transitive via BFS from the requesting user's own node.
| Level | Relation | Grants |
|-------|----------|--------|
| 1 | `can_read` | Read / show / list |
| 2 | `can_create_rel` | Create relations pointing to this node |
| 3 | `can_write` | Update title, content, tags |
| 4 | `has_ownership` | Full control including delete and granting access |
- Creators automatically get `has_ownership` on nodes they create.
- Namespace nodes own regular nodes within them; users own their namespaces.
- Deleting an owner cascades to all nodes it owns.
- User nodes and namespace nodes are globally readable.
```bash
# Grant bob read access to a node
ax update <bob-user-id> --rel can_read:<node-id>
# Grant bob write access
ax update <bob-user-id> --rel can_write:<node-id>
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| `AX_USER` | Override current username |
| `EDITOR` | Editor for `ax edit` (default: `vi`) |
## Database Location
`ax` searches for `.ax.db` upward from CWD (like git finds `.git`), so commands work from any subdirectory.

View File

@@ -43,7 +43,7 @@ var addCmd = &cobra.Command{
input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_prio::" + cPrio), Target: ""})
}
if cNamespace != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelInNamespace, Target: cNamespace})
input.Namespace = cNamespace
}
if cAssignee != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelAssignee, Target: cAssignee})

View File

@@ -46,7 +46,7 @@ var listCmd = &cobra.Command{
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_type::" + lType), Target: ""})
}
if lNamespace != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: lNamespace})
filter.Namespace = lNamespace
}
if lAssignee != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: lAssignee})

View File

@@ -49,7 +49,7 @@ var (
"low": {" ", "low", cDim},
"": {" ", "n/a", cDim},
}
relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007", "in_namespace": "\uf07b"}
relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007"}
prioRanks = map[string]int{"high": 3, "medium": 2, "low": 1}
statusRanks = map[string]int{"open": 2, "": 1, "done": 0}
)
@@ -94,24 +94,12 @@ func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, json
})
for _, n := range nodes {
n_rels := n.Relations
ns_rel_node_ids := n_rels[string(models.RelInNamespace)]
ns_rel_node_titles := make([]string, 0, len(ns_rel_node_ids))
for _, id := range ns_rel_node_ids {
ns_rel_node, err := svc.GetByID(id)
if err != nil {
ns_rel_node_titles = append(ns_rel_node_titles, id)
continue
}
ns_rel_node_titles = append(ns_rel_node_titles, ns_rel_node.Title)
}
fmt.Fprintf(w, " %s %s %s %s %s %s %s",
fmt.Fprintf(w, " %s %s %s %s %s %s",
cDim.Sprint(n.ID),
render(prioRM, n.GetProperty("prio"), true),
render(statusRM, n.GetProperty("status"), true),
render(typeRM, n.GetProperty("type"), true),
cTitle.Sprint(truncate(n.Title, 80)),
cDim.Sprint("["+strings.Join(ns_rel_node_titles, ",")+"]"),
dueDateShort(n.DueDate),
)
tags := n.GetDisplayTags()

View File

@@ -61,7 +61,7 @@ var updateCmd = &cobra.Command{
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_prio::" + uPrio), Target: ""})
}
if cmd.Flags().Changed("namespace") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelInNamespace, Target: uNamespace})
input.Namespace = &uNamespace
}
if cmd.Flags().Changed("assignee") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelAssignee, Target: uAssignee})

View File

@@ -33,9 +33,6 @@ func TestCRUD(t *testing.T) {
if len(n.Relations["created"]) == 0 {
t.Error("expected created relation to be set")
}
if len(n.Relations["in_namespace"]) == 0 {
t.Error("expected in_namespace relation to be set")
}
if n.CreatedAt == "" || n.UpdatedAt == "" {
t.Error("expected timestamps to be set")
}
@@ -73,9 +70,6 @@ func TestCRUD(t *testing.T) {
if n.Content != "some body" {
t.Errorf("content: want %q, got %q", "some body", n.Content)
}
if len(n.Relations["in_namespace"]) == 0 {
t.Error("expected in_namespace relation")
}
if len(n.Relations["assignee"]) == 0 {
t.Error("expected assignee relation")
}

View File

@@ -168,10 +168,6 @@ func TestNamespaceExplicitCreate(t *testing.T) {
nsNode := env.parseNode(env.mustAx("add", "myworkspace", "--type", "namespace", "--json"))
if !nsNode.HasRelation("in_namespace", nsNode.ID) {
t.Errorf("expected namespace to have in_namespace pointing to itself, got relations: %v", nsNode.Relations)
}
users := env.parseNodes(env.mustAx("list", "--type", "user", "--json"))
var userNode *NodeResponse
for i := range users {

View File

@@ -89,7 +89,7 @@ func (n *Node) AddRelation(relType RelType, target string) {
if n.Relations == nil {
n.Relations = make(map[string][]string)
}
if relType == RelAssignee || relType == RelCreated || relType == RelInNamespace {
if relType == RelAssignee || relType == RelCreated {
n.Relations[string(relType)] = []string{target}
return
}

View File

@@ -13,7 +13,6 @@ const (
RelRelated RelType = "related"
RelCreated RelType = "created"
RelAssignee RelType = "assignee"
RelInNamespace RelType = "in_namespace"
RelMentions RelType = "mentions"
// Permission rels (subject → object). Levels are inclusive and transitive.

View File

@@ -77,7 +77,7 @@ func (s *server) listNodes(w http.ResponseWriter, r *http.Request) {
}
}
if v := q.Get("namespace"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: v})
filter.Namespace = v
}
if v := q.Get("assignee"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: v})

View File

@@ -72,6 +72,9 @@ func (c *apiClient) GetByID(id string) (*models.Node, error) {
func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) {
q := url.Values{}
if filter.Namespace != "" {
q.Set("namespace", filter.Namespace)
}
for _, r := range filter.Rels {
if r.Target == "" {
q.Add("rel", string(r.Type))

View File

@@ -37,17 +37,20 @@ type AddInput struct {
Title string
Content string
DueDate string
Namespace string // namespace name or ID; defaults to the user's personal namespace
Rels []RelInput
}
// UpdateInput describes changes to apply to an existing node.
// AddRels and RemoveRels accept both tag rels (Target == "") and edge rels.
// Setting _status::done in AddRels is rejected when the node has open blockers.
// Adding assignee or in_namespace rels replaces the previous single target.
// Adding an assignee rel replaces the previous single target.
// Setting Namespace transfers ownership from the current namespace to the new one.
type UpdateInput struct {
Title *string
Content *string
DueDate *string // nil = no change; pointer to "" = clear due date
Namespace *string // nil = no change; namespace name or ID to move node into
AddRels []RelInput
RemoveRels []RelInput
}
@@ -56,6 +59,7 @@ type UpdateInput struct {
// Tag filters (Target == "") match by rel_name prefix.
// Edge filters (Target != "") are resolved to node IDs.
type ListFilter struct {
Namespace string // when non-empty, only return nodes owned by this namespace
Rels []RelInput
HasDueDate bool // when true, only return nodes that have a due date set
DueWithin *int // when non-nil, only return nodes due within this many days (includes overdue)

View File

@@ -75,7 +75,7 @@ const (
// namespaces are globally readable and any node can reference them.
func isReferenceRel(t models.RelType) bool {
switch t {
case models.RelAssignee, models.RelCreated, models.RelMentions, models.RelInNamespace:
case models.RelAssignee, models.RelCreated, models.RelMentions:
return true
}
return false
@@ -208,6 +208,28 @@ func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) {
}
func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
// Resolve namespace filter to owned node IDs.
var nsOwnedIDs map[string]bool
if filter.Namespace != "" {
nsID, _ := s.resolveIDByNameAndType(s.store, filter.Namespace, "namespace")
if nsID == "" {
if exists, _ := s.store.NodeExists(filter.Namespace); exists {
nsID = filter.Namespace
}
}
if nsID == "" {
return nil, nil // namespace not found
}
nsNode, err := s.store.GetNode(nsID)
if err != nil {
return nil, nil
}
nsOwnedIDs = make(map[string]bool)
for _, ownedID := range nsNode.Relations[string(models.RelHasOwnership)] {
nsOwnedIDs[ownedID] = true
}
}
var storeFilters []*models.Rel
for _, ri := range filter.Rels {
if ri.Target == "" {
@@ -230,9 +252,13 @@ func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
}
var result []*models.Node
for _, n := range nodes {
if pc.canRead(n.ID) {
result = append(result, n)
if !pc.canRead(n.ID) {
continue
}
if nsOwnedIDs != nil && !nsOwnedIDs[n.ID] {
continue
}
result = append(result, n)
}
if filter.HasDueDate || filter.DueWithin != nil {
@@ -323,13 +349,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
}
}
hasNamespace := false
for _, ri := range input.Rels {
if ri.Type == models.RelInNamespace && ri.Target != "" {
hasNamespace = true
}
}
dueDate, err := parseDueDate(input.DueDate)
if err != nil {
return nil, err
@@ -364,9 +383,8 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
}
}
// Edge rels. Track the namespace the node is placed in for ownership.
// Edge rels.
hasCreated := false
var actualNsID string
for _, ri := range input.Rels {
if ri.Target == "" {
continue // already stored as tag
@@ -378,9 +396,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
if err != nil {
return err
}
if ri.Type == models.RelInNamespace {
actualNsID = resolved
}
if ri.Type == models.RelHasOwnership {
// Ownership transfer: remove existing owner of the target.
existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}})
@@ -393,18 +408,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
}
}
// Default namespace.
if !hasNamespace {
nsID, err := s.resolveNamespaceRef(st, s.userID)
if err != nil {
return err
}
if err := st.AddRel(id, string(models.RelInNamespace), nsID); err != nil {
return err
}
actualNsID = nsID
}
// Default created.
if !hasCreated {
userID, err := s.resolveUserRef(st, s.userID)
@@ -417,39 +420,30 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
}
// Grant ownership of the new node.
// Namespace nodes are owned by their creator. All other nodes are owned
// by the namespace they belong to — the user retains transitive ownership
// through the namespace's own ownership chain (e.g. user→owns→default-ns→owns→node).
// Namespace nodes are owned by their creator (user node).
// All other nodes are owned by the namespace they belong to — the user
// retains transitive ownership through the namespace's own ownership chain
// (e.g. user→has_ownership→default-ns→has_ownership→node).
creatorID, err := s.resolveUserRef(st, s.userID)
if err != nil {
return err
}
ownerID := creatorID
if tmp.GetProperty("type") != "namespace" && actualNsID != "" {
ownerID = actualNsID
if tmp.GetProperty("type") != "namespace" {
nsRef := input.Namespace
if nsRef == "" {
nsRef = s.userID
}
nsID, err := s.resolveNamespaceRef(st, nsRef)
if err != nil {
return err
}
ownerID = nsID
}
if err := st.AddRel(ownerID, string(models.RelHasOwnership), id); err != nil {
return err
}
// Namespace bootstrap: when creating a namespace node directly, apply the
// same setup as ensureNamespace — self in_namespace and creator ownership.
if tmp.GetProperty("type") == "namespace" {
if !hasNamespace {
// Replace the default namespace rel (user's ns) with self-reference.
userNsID, _ := s.resolveIDByNameAndType(st, s.userID, "namespace")
if userNsID != "" {
if err := st.RemoveRel(id, string(models.RelInNamespace), userNsID); err != nil {
return err
}
}
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return err
}
}
// Creator already gets ownership via the block above; nothing more to do.
}
return nil
})
if err != nil {
@@ -470,8 +464,8 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
return nil, err
}
// Field/tag changes and rel removals require can_write on the node.
needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil
// Field/tag changes, rel removals, and namespace change require can_write on the node.
needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil || input.Namespace != nil
for _, ri := range input.AddRels {
if ri.Target == "" {
needsWrite = true
@@ -619,7 +613,7 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
return err
}
// Single-value relations replace the previous target.
if ri.Type == models.RelAssignee || ri.Type == models.RelInNamespace {
if ri.Type == models.RelAssignee {
for _, oldTgt := range currentRels[string(ri.Type)] {
if err := st.RemoveRel(id, string(ri.Type), oldTgt); err != nil {
return err
@@ -651,6 +645,24 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
}
}
// Namespace change: transfer ownership from the current namespace to the new one.
if input.Namespace != nil {
newNsID, err := s.resolveNamespaceRef(st, *input.Namespace)
if err != nil {
return err
}
// Remove ownership from any current namespace owner.
currentOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: id}})
for _, owner := range currentOwners {
if owner.GetProperty("type") == "namespace" {
st.RemoveRel(owner.ID, string(models.RelHasOwnership), id) //nolint:errcheck
}
}
if err := st.AddRel(newNsID, string(models.RelHasOwnership), id); err != nil {
return err
}
}
return nil
})
if err != nil {
@@ -770,8 +782,6 @@ func (s *nodeServiceImpl) resolveRelTarget(st store.GraphStore, ri RelInput) (st
switch ri.Type {
case models.RelAssignee, models.RelCreated, models.RelMentions:
return s.resolveUserRef(st, ri.Target)
case models.RelInNamespace:
return s.resolveNamespaceRef(st, ri.Target)
default:
// Permission rels and all other edge rels expect raw node IDs.
return ri.Target, nil
@@ -788,8 +798,6 @@ func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string)
switch relType {
case models.RelAssignee, models.RelCreated, models.RelMentions:
nodeType = "user"
case models.RelInNamespace:
nodeType = "namespace"
default:
// Permission rels and other edge rels use raw node IDs.
return "", false
@@ -843,9 +851,6 @@ func (s *nodeServiceImpl) ensureGlobalNamespace(st store.GraphStore) (string, er
if err := st.AddRel(id, "_type::namespace", ""); err != nil {
return "", err
}
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return "", err
}
// Self-owned so no single user controls it.
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
return "", err
@@ -916,9 +921,6 @@ func (s *nodeServiceImpl) ensureNamespace(st store.GraphStore, name string) (str
if err := st.AddRel(id, "_type::namespace", ""); err != nil {
return "", err
}
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return "", err
}
userID, err := s.resolveUserRef(st, s.userID)
if err != nil {
return "", err

View File

@@ -74,31 +74,20 @@ func FindAndOpenSQLiteStore() (GraphStore, error) {
}
// FindOrInitSQLiteStore is like FindAndOpenSQLiteStore but intended for server
// mode: if no .ax.db is found it creates and initialises one in the current
// working directory instead of returning an error.
// mode: if no database is found it creates and initialises one in the
// ~/.local/share/ax/ directory instead of returning an error.
func FindOrInitSQLiteStore() (GraphStore, error) {
dir, err := filepath.Abs(".")
dataRoot, err := FindDataRoot(".local", "share")
if err != nil {
return nil, fmt.Errorf("failed to find data dir: %w", err)
}
dbPath := filepath.Join(dataRoot, "ax.db")
if _, err := os.Stat(dbPath); err != nil {
if err := InitSQLiteStore(dbPath); err != nil {
return nil, err
}
for {
dbpath := filepath.Join(dir, ".ax.db")
if _, err := os.Stat(dbpath); err == nil {
return NewSQLiteStore(dbpath)
}
if parent := filepath.Dir(dir); parent == dir {
break
} else {
dir = parent
}
}
// Not found — create and initialise in CWD.
cwd, _ := filepath.Abs(".")
dbpath := filepath.Join(cwd, ".ax.db")
if err := InitSQLiteStore(dbpath); err != nil {
return nil, err
}
return NewSQLiteStore(dbpath)
return NewSQLiteStore(dbPath)
}
// NewSQLiteStore opens a SQLite database at the given path, runs a one-time

View File

@@ -22,7 +22,7 @@ func LoadSession() (*Session, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
return &Session{path: path}, nil
}
return nil, err
}