Changeset 0.19.0.1 (#82)

This commit is contained in:
2026-02-28 23:46:23 +00:00
parent 091ce2af6a
commit 748f49bedd
30 changed files with 3873 additions and 151 deletions

View File

@@ -0,0 +1,157 @@
-- v0.19.0: Projects / Workspaces
--
-- New: projects table
-- New: project_channels junction table (1:1 channel→project)
-- New: project_knowledge_bases junction table
-- New: project_notes junction table
-- New: channels.project_id denormalized FK
-- Changed: resource_grants.resource_type CHECK includes 'project'
-- Deprecated: channels.folder (column retained, no longer written)
-- =========================================
-- 1. Projects Table
-- =========================================
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
description TEXT,
color VARCHAR(7), -- hex color e.g. '#3B82F6'
icon VARCHAR(50), -- emoji or icon name
scope VARCHAR(20) NOT NULL DEFAULT 'personal'
CHECK (scope IN ('personal', 'team', 'global')),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
is_archived BOOLEAN NOT NULL DEFAULT false,
settings JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id);
CREATE INDEX IF NOT EXISTS idx_projects_team ON projects(team_id) WHERE team_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_projects_scope ON projects(scope);
DROP TRIGGER IF EXISTS projects_updated_at ON projects;
CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON TABLE projects IS 'Organizes channels, KBs, and notes into named workspaces';
COMMENT ON COLUMN projects.scope IS 'personal=owner only, team=team members, global=all users';
-- =========================================
-- 2. Project ↔ Channel Junction
-- =========================================
-- One project per channel (UNIQUE on channel_id).
-- Source of truth for position and folder within the project.
CREATE TABLE IF NOT EXISTS project_channels (
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
folder TEXT, -- sub-folder within the project
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (project_id, channel_id),
UNIQUE (channel_id) -- one project per channel
);
CREATE INDEX IF NOT EXISTS idx_project_channels_project ON project_channels(project_id);
CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id);
COMMENT ON TABLE project_channels IS 'Links channels to projects. UNIQUE(channel_id) enforces one-project-per-channel.';
COMMENT ON COLUMN project_channels.position IS 'Sort order within the project sidebar group';
COMMENT ON COLUMN project_channels.folder IS 'Optional sub-folder path within the project';
-- =========================================
-- 3. Project ↔ Knowledge Base Junction
-- =========================================
CREATE TABLE IF NOT EXISTS project_knowledge_bases (
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
auto_search BOOLEAN NOT NULL DEFAULT false,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (project_id, kb_id)
);
CREATE INDEX IF NOT EXISTS idx_project_kb_project ON project_knowledge_bases(project_id);
CREATE INDEX IF NOT EXISTS idx_project_kb_kb ON project_knowledge_bases(kb_id);
COMMENT ON TABLE project_knowledge_bases IS 'Binds KBs to Projects — injected at completion time for all project channels';
COMMENT ON COLUMN project_knowledge_bases.auto_search IS 'true = auto-prepend top-K results; false = kb_search tool only';
-- =========================================
-- 4. Project ↔ Note Junction
-- =========================================
CREATE TABLE IF NOT EXISTS project_notes (
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (project_id, note_id)
);
CREATE INDEX IF NOT EXISTS idx_project_notes_project ON project_notes(project_id);
CREATE INDEX IF NOT EXISTS idx_project_notes_note ON project_notes(note_id);
COMMENT ON TABLE project_notes IS 'Links notes to projects for organizational grouping';
-- =========================================
-- 5. Denormalized project_id on Channels
-- =========================================
-- Fast sidebar query: SELECT ... FROM channels WHERE project_id = $1
-- Source of truth is project_channels; this is updated atomically.
ALTER TABLE channels
ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE project_id IS NOT NULL;
-- =========================================
-- 6. Extend resource_grants CHECK
-- =========================================
-- Add 'project' to allowed resource_type values.
-- Postgres ALTER TABLE ... ALTER CONSTRAINT is not supported; drop + re-add.
ALTER TABLE resource_grants DROP CONSTRAINT IF EXISTS resource_grants_resource_type_check;
ALTER TABLE resource_grants ADD CONSTRAINT resource_grants_resource_type_check
CHECK (resource_type IN ('persona', 'knowledge_base', 'project'));
-- =========================================
-- 7. Best-effort folder → project migration
-- =========================================
-- Create a personal project for each distinct folder value, then
-- associate the channels. Skip if no folder values exist.
-- This is idempotent: re-running won't duplicate projects because
-- we check for existing project_id.
DO $$
DECLARE
_folder TEXT;
_user_id UUID;
_proj_id UUID;
_ch_id UUID;
BEGIN
FOR _user_id, _folder IN
SELECT DISTINCT user_id, folder FROM channels
WHERE folder IS NOT NULL AND folder != '' AND project_id IS NULL
LOOP
-- Create project named after the folder
INSERT INTO projects (name, scope, owner_id)
VALUES (_folder, 'personal', _user_id)
RETURNING id INTO _proj_id;
-- Associate channels
FOR _ch_id IN
SELECT id FROM channels
WHERE user_id = _user_id AND folder = _folder AND project_id IS NULL
LOOP
INSERT INTO project_channels (project_id, channel_id, position)
VALUES (_proj_id, _ch_id, 0)
ON CONFLICT DO NOTHING;
UPDATE channels SET project_id = _proj_id WHERE id = _ch_id;
END LOOP;
END LOOP;
END
$$;

View File

@@ -0,0 +1,131 @@
-- v0.19.0: Projects / Workspaces (SQLite)
-- =========================================
-- 1. Projects Table
-- =========================================
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
color TEXT,
icon TEXT,
scope TEXT NOT NULL DEFAULT 'personal'
CHECK (scope IN ('personal', 'team', 'global')),
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
is_archived INTEGER NOT NULL DEFAULT 0,
settings TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id);
CREATE INDEX IF NOT EXISTS idx_projects_team ON projects(team_id);
CREATE INDEX IF NOT EXISTS idx_projects_scope ON projects(scope);
CREATE TRIGGER IF NOT EXISTS projects_updated_at AFTER UPDATE ON projects
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE projects SET updated_at = datetime('now') WHERE id = NEW.id;
END;
-- =========================================
-- 2. Project ↔ Channel Junction
-- =========================================
CREATE TABLE IF NOT EXISTS project_channels (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
folder TEXT,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, channel_id),
UNIQUE (channel_id)
);
CREATE INDEX IF NOT EXISTS idx_project_channels_project ON project_channels(project_id);
CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id);
-- =========================================
-- 3. Project ↔ Knowledge Base Junction
-- =========================================
CREATE TABLE IF NOT EXISTS project_knowledge_bases (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
kb_id TEXT NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
auto_search INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, kb_id)
);
CREATE INDEX IF NOT EXISTS idx_project_kb_project ON project_knowledge_bases(project_id);
CREATE INDEX IF NOT EXISTS idx_project_kb_kb ON project_knowledge_bases(kb_id);
-- =========================================
-- 4. Project ↔ Note Junction
-- =========================================
CREATE TABLE IF NOT EXISTS project_notes (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, note_id)
);
CREATE INDEX IF NOT EXISTS idx_project_notes_project ON project_notes(project_id);
CREATE INDEX IF NOT EXISTS idx_project_notes_note ON project_notes(note_id);
-- =========================================
-- 5. Denormalized project_id on Channels
-- =========================================
ALTER TABLE channels ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id);
-- =========================================
-- 6. Extend resource_grants CHECK
-- =========================================
-- SQLite cannot ALTER CHECK constraints. The original CREATE TABLE
-- defined CHECK(resource_type IN ('persona','knowledge_base')).
-- SQLite does not enforce CHECK on existing rows, and new inserts
-- with 'project' will be accepted only if we recreate the table.
-- For pragmatism: we accept 'project' values via application-level
-- validation and leave the SQLite CHECK as-is (it's advisory in
-- SQLite when PRAGMA ignore_check_constraints is implicitly off
-- for existing data, but CHECK IS enforced on INSERT).
--
-- Workaround: recreate the table with the new CHECK. This is safe
-- because resource_grants typically has very few rows.
CREATE TABLE IF NOT EXISTS resource_grants_new (
id TEXT PRIMARY KEY,
resource_type TEXT NOT NULL
CHECK (resource_type IN ('persona', 'knowledge_base', 'project')),
resource_id TEXT NOT NULL,
grant_scope TEXT NOT NULL DEFAULT 'team_only'
CHECK (grant_scope IN ('team_only', 'global', 'groups')),
granted_groups TEXT NOT NULL DEFAULT '[]',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(resource_type, resource_id)
);
INSERT OR IGNORE INTO resource_grants_new
SELECT id, resource_type, resource_id, grant_scope, granted_groups,
created_by, created_at, updated_at
FROM resource_grants;
DROP TABLE IF EXISTS resource_grants;
ALTER TABLE resource_grants_new RENAME TO resource_grants;
CREATE INDEX IF NOT EXISTS idx_resource_grants_resource
ON resource_grants(resource_type, resource_id);
CREATE TRIGGER IF NOT EXISTS resource_grants_updated_at AFTER UPDATE ON resource_grants
FOR EACH ROW WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE resource_grants SET updated_at = datetime('now') WHERE id = NEW.id;
END;

View File

@@ -266,10 +266,14 @@ func TruncateAll(t *testing.T) {
"channel_members",
"channel_knowledge_bases",
"persona_knowledge_bases",
"project_notes",
"project_knowledge_bases",
"project_channels",
"kb_chunks",
"kb_documents",
"knowledge_bases",
"channels",
"projects",
"user_model_settings",
"model_catalog",
"persona_grants",