Skip to content
Closed
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
98 changes: 98 additions & 0 deletions packages/core/src/applicability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
APPLIES_TO_DEFAULT,
APPLIES_TO_LAYERS,
type Applicability,
normalizeApplicability,
validateApplicability,
} from "./applicability.js";

describe("validateApplicability", () => {
it("returns the default applicability when neither field is provided", () => {
const result = validateApplicability({});
expect(result).toEqual<Applicability>({ applies_to: "project", applies_to_key: null });
});

it("returns the default when both fields are explicitly undefined", () => {
const result = validateApplicability({ applies_to: undefined, applies_to_key: undefined });
expect(result.applies_to).toBe(APPLIES_TO_DEFAULT);
});

it("accepts each known layer", () => {
for (const layer of APPLIES_TO_LAYERS) {
const needsKey = layer === "org" || layer === "toolchain";
const result = validateApplicability({
applies_to: layer,
applies_to_key: needsKey ? "key-1" : null,
});
expect(result.applies_to).toBe(layer);
}
});

it("normalizes case and whitespace on applies_to", () => {
const result = validateApplicability({ applies_to: " USER " });
expect(result.applies_to).toBe("user");
});

it("rejects unknown applies_to values with a structured error", () => {
expect(() => validateApplicability({ applies_to: "global" })).toThrow(/applies_to/);
});

it("requires applies_to_key when applies_to is 'org'", () => {
expect(() => validateApplicability({ applies_to: "org" })).toThrow(/applies_to_key/);
expect(() => validateApplicability({ applies_to: "org", applies_to_key: " " })).toThrow(
/applies_to_key/,
);
});

it("requires applies_to_key when applies_to is 'toolchain'", () => {
expect(() => validateApplicability({ applies_to: "toolchain" })).toThrow(/applies_to_key/);
});

it("rejects applies_to_key when applies_to is 'user'", () => {
expect(() => validateApplicability({ applies_to: "user", applies_to_key: "anything" })).toThrow(
/applies_to_key/,
);
});

it("rejects applies_to_key when applies_to is 'project'", () => {
expect(() =>
validateApplicability({ applies_to: "project", applies_to_key: "anything" }),
).toThrow(/applies_to_key/);
});

it("trims and preserves a valid applies_to_key for org/toolchain", () => {
const org = validateApplicability({ applies_to: "org", applies_to_key: " acme " });
expect(org.applies_to_key).toBe("acme");
const tc = validateApplicability({ applies_to: "toolchain", applies_to_key: "pnpm" });
expect(tc.applies_to_key).toBe("pnpm");
});
});

describe("normalizeApplicability (row → Applicability)", () => {
it("defaults a missing applies_to to 'project'", () => {
const result = normalizeApplicability({ applies_to: null, applies_to_key: null });
expect(result).toEqual<Applicability>({ applies_to: "project", applies_to_key: null });
});

it("defaults a blank applies_to to 'project' (downgrade safety)", () => {
const result = normalizeApplicability({ applies_to: " ", applies_to_key: null });
expect(result.applies_to).toBe("project");
});

it("treats an unknown applies_to as 'project' (downgrade safety, no throw)", () => {
const result = normalizeApplicability({ applies_to: "future-layer", applies_to_key: "x" });
expect(result.applies_to).toBe("project");
expect(result.applies_to_key).toBeNull();
});

it("retains a known layer and trims its key", () => {
const result = normalizeApplicability({ applies_to: "ORG", applies_to_key: " acme " });
expect(result).toEqual<Applicability>({ applies_to: "org", applies_to_key: "acme" });
});

it("drops the key for layers that do not allow one", () => {
const result = normalizeApplicability({ applies_to: "user", applies_to_key: "leaked" });
expect(result.applies_to_key).toBeNull();
});
});
90 changes: 90 additions & 0 deletions packages/core/src/applicability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Applicability of a memory: where the rule applies (user / org / toolchain /
* project). Orthogonal to scope_id (sharing domain — WHO can read) and to
* memory_concept_refs (WHAT the memory is about). Foundation for the layered
* sticky-rules pack band (bead codemem-uyhx → codemem-qqmd).
*/

export const APPLIES_TO_LAYERS = ["user", "org", "toolchain", "project"] as const;

export type AppliesTo = (typeof APPLIES_TO_LAYERS)[number];

export const APPLIES_TO_DEFAULT: AppliesTo = "project";

const APPLIES_TO_REQUIRES_KEY: ReadonlySet<AppliesTo> = new Set(["org", "toolchain"]);

const APPLIES_TO_VALUES: ReadonlySet<string> = new Set(APPLIES_TO_LAYERS);

export interface Applicability {
applies_to: AppliesTo;
applies_to_key: string | null;
}

export interface ApplicabilityInput {
applies_to?: AppliesTo | string | null;
applies_to_key?: string | null;
}

function normalizeLayer(value: unknown): AppliesTo | null {
if (typeof value !== "string") return null;
const lower = value.trim().toLowerCase();
return APPLIES_TO_VALUES.has(lower) ? (lower as AppliesTo) : null;
}

function normalizeKey(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}

/**
* Strict validation for the write path. Rejects unknown layers and enforces
* the applies_to_key contract: required for org/toolchain, forbidden for
* user/project. Returns a normalized Applicability ready for insert.
*/
export function validateApplicability(input: ApplicabilityInput): Applicability {
const rawLayer = input.applies_to;
if (rawLayer == null) {
const key = normalizeKey(input.applies_to_key);
if (key !== null) {
throw new Error(
`applies_to_key is only valid when applies_to is 'org' or 'toolchain' (got applies_to_key='${key}' with no applies_to)`,
);
}
return { applies_to: APPLIES_TO_DEFAULT, applies_to_key: null };
}

const layer = normalizeLayer(rawLayer);
if (layer === null) {
throw new Error(
`Invalid applies_to '${String(rawLayer)}'. Allowed: ${APPLIES_TO_LAYERS.join(", ")}`,
);
}

const key = normalizeKey(input.applies_to_key);
const needsKey = APPLIES_TO_REQUIRES_KEY.has(layer);
if (needsKey && key === null) {
throw new Error(
`applies_to_key is required when applies_to is '${layer}' (got missing or blank key)`,
);
}
if (!needsKey && key !== null) {
throw new Error(`applies_to_key must be null when applies_to is '${layer}' (got '${key}')`);
}
return { applies_to: layer, applies_to_key: key };
}

/**
* Lenient normalization for the read/replication path. Unknown layers degrade
* to 'project' (downgrade safety: older peers that emit an unrecognized layer
* never poison the local store). Keys that should not exist for the resolved
* layer are dropped.
*/
export function normalizeApplicability(raw: {
applies_to: unknown;
applies_to_key: unknown;
}): Applicability {
const layer = normalizeLayer(raw.applies_to) ?? APPLIES_TO_DEFAULT;
const key = APPLIES_TO_REQUIRES_KEY.has(layer) ? normalizeKey(raw.applies_to_key) : null;
return { applies_to: layer, applies_to_key: key };
}
137 changes: 137 additions & 0 deletions packages/core/src/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1040,3 +1040,140 @@ describe("migrateLegacyDbPath", () => {
expect(existsSync(target)).toBe(true);
});
});

describe("memory_items applies_to columns (G1.1)", () => {
let tmpDir: string;
let db: Database | undefined;

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "codemem-applies-to-"));
});

afterEach(() => {
db?.close();
rmSync(tmpDir, { recursive: true, force: true });
});

it("adds applies_to (NOT NULL, default 'project') and applies_to_key (nullable) on fresh bootstrap", () => {
db = connect(join(tmpDir, "fresh.sqlite"));

expect(columnExists(db, "memory_items", "applies_to")).toBe(true);
expect(columnExists(db, "memory_items", "applies_to_key")).toBe(true);

const appliesTo = db
.prepare(
"SELECT \"notnull\" AS is_not_null, dflt_value FROM pragma_table_info('memory_items') WHERE name = 'applies_to'",
)
.get() as { is_not_null: number; dflt_value: string | null };
expect(appliesTo.is_not_null).toBe(1);
expect(appliesTo.dflt_value).toMatch(/'project'/);

const appliesToKey = db
.prepare(
"SELECT \"notnull\" AS is_not_null FROM pragma_table_info('memory_items') WHERE name = 'applies_to_key'",
)
.get() as { is_not_null: number };
expect(appliesToKey.is_not_null).toBe(0);
});

it("creates composite index idx_memory_items_applies_to on (applies_to, applies_to_key)", () => {
db = connect(join(tmpDir, "fresh.sqlite"));

expect(hasIndex(db, "idx_memory_items_applies_to")).toBe(true);

const cols = db
.prepare("SELECT name FROM pragma_index_info('idx_memory_items_applies_to') ORDER BY seqno")
.all() as { name: string }[];
expect(cols.map((c) => c.name)).toEqual(["applies_to", "applies_to_key"]);
});

it("is idempotent across reconnects", () => {
const dbPath = join(tmpDir, "idempotent.sqlite");
db = connect(dbPath);
db.close();
db = connect(dbPath);

expect(columnExists(db, "memory_items", "applies_to")).toBe(true);
expect(columnExists(db, "memory_items", "applies_to_key")).toBe(true);
expect(hasIndex(db, "idx_memory_items_applies_to")).toBe(true);
});

it("defaults inserts without applies_to to 'project' and applies_to_key to NULL", () => {
db = connect(join(tmpDir, "fresh.sqlite"));

db.prepare("INSERT INTO sessions (id, started_at) VALUES (?, ?)").run(
1,
"2026-01-01T00:00:00Z",
);
db.prepare(
"INSERT INTO memory_items (id, session_id, kind, title, body_text, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(1, 1, "rule", "test", "body", "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z");

const row = db
.prepare("SELECT applies_to, applies_to_key FROM memory_items WHERE id = 1")
.get() as { applies_to: string; applies_to_key: string | null };
expect(row.applies_to).toBe("project");
expect(row.applies_to_key).toBeNull();
});

it("rejects raw-SQL inserts with an unknown applies_to via CHECK constraint", () => {
db = connect(join(tmpDir, "fresh.sqlite"));
db.prepare("INSERT INTO sessions (id, started_at) VALUES (?, ?)").run(
1,
"2026-01-01T00:00:00Z",
);
expect(() =>
db
.prepare(
"INSERT INTO memory_items (id, session_id, kind, title, body_text, created_at, updated_at, applies_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.run(
1,
1,
"rule",
"test",
"body",
"2026-01-01T00:00:00Z",
"2026-01-01T00:00:00Z",
"global",
),
).toThrow(/CHECK constraint failed/i);
});

it("uses idx_memory_items_applies_to for the layered-band query", () => {
db = connect(join(tmpDir, "fresh.sqlite"));

// Seed enough varied rows for the planner to prefer the index over a scan.
db.prepare("INSERT INTO sessions (id, started_at) VALUES (?, ?)").run(
1,
"2026-01-01T00:00:00Z",
);
const insert = db.prepare(
"INSERT INTO memory_items (id, session_id, kind, title, body_text, created_at, updated_at, applies_to, applies_to_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
);
const layers = ["user", "org", "toolchain", "project"];
for (let i = 0; i < 100; i += 1) {
insert.run(
i + 1,
1,
"rule",
`m${i}`,
"body",
"2026-01-01T00:00:00Z",
"2026-01-01T00:00:00Z",
layers[i % layers.length],
i % 4 < 2 ? null : `k${i % 8}`,
);
}
db.exec("ANALYZE");

const plan = db
.prepare(
"EXPLAIN QUERY PLAN SELECT id FROM memory_items WHERE applies_to = ? AND (applies_to_key = ? OR applies_to_key IS NULL)",
)
.all("user", "default") as { detail: string }[];

const detail = plan.map((p) => p.detail).join(" | ");
expect(detail).toMatch(/idx_memory_items_applies_to/);
});
});
19 changes: 19 additions & 0 deletions packages/core/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,25 @@ export function ensureAdditiveSchemaCompatibility(db: DatabaseType): void {
} catch {
// Keep additive compatibility best-effort for index creation.
}

// G1.1: applicability hierarchy (user/org/toolchain/project). Distinct
// from scope_id (sharing domain) — answers WHERE a rule applies, not WHO
// can read it. CHECK constraint is the DB-layer guard against raw-SQL
// or older-peer writes that bypass validateApplicability().
addColumnIfMissing(
db,
"memory_items",
"applies_to",
"TEXT NOT NULL DEFAULT 'project' CHECK (applies_to IN ('user', 'org', 'toolchain', 'project'))",
);
addColumnIfMissing(db, "memory_items", "applies_to_key", "TEXT");
try {
db.exec(
"CREATE INDEX IF NOT EXISTS idx_memory_items_applies_to ON memory_items(applies_to, applies_to_key)",
);
} catch {
// Keep additive compatibility best-effort for index creation.
}
}

if (tableExists(db, "replication_ops")) {
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { sql } from "drizzle-orm";
import {
check,
index,
integer,
primaryKey,
Expand All @@ -12,6 +13,7 @@ import {
text,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
import { APPLIES_TO_DEFAULT, APPLIES_TO_LAYERS } from "./applicability.js";

export const sessions = sqliteTable("sessions", {
id: integer("id").primaryKey(),
Expand Down Expand Up @@ -163,6 +165,8 @@ export const memoryItems = sqliteTable(
dedup_key: text("dedup_key"),
import_key: text("import_key"),
scope_id: text("scope_id"),
applies_to: text("applies_to").notNull().default(APPLIES_TO_DEFAULT),
applies_to_key: text("applies_to_key"),
},
(table) => [
index("idx_memory_items_active_created").on(table.active, table.created_at),
Expand All @@ -183,6 +187,11 @@ export const memoryItems = sqliteTable(
uniqueIndex("idx_memory_items_same_session_dedup_unique")
.on(table.session_id, table.kind, table.visibility, table.workspace_id, table.dedup_key)
.where(sql`active = 1 AND dedup_key IS NOT NULL`),
index("idx_memory_items_applies_to").on(table.applies_to, table.applies_to_key),
check(
"memory_items_applies_to_valid",
sql.raw(`applies_to IN (${APPLIES_TO_LAYERS.map((l) => `'${l}'`).join(", ")})`),
),
],
);

Expand Down
Loading