From 0ccddd35e1e83554297212fc725435c4ac25eb96 Mon Sep 17 00:00:00 2001 From: thisisnkc Date: Tue, 7 Apr 2026 13:41:29 +0530 Subject: [PATCH 1/4] feat: add schema diff command to cli package --- packages/cli/src/commands/schema/diff.ts | 246 ++++++++++++++++++++++ packages/core/src/public-api.ts | 15 ++ packages/core/src/schema/diff-schema.ts | 247 +++++++++++++++++++++++ packages/core/src/schema/read-schema.ts | 162 +++++++++++++++ 4 files changed, 670 insertions(+) create mode 100644 packages/cli/src/commands/schema/diff.ts create mode 100644 packages/core/src/schema/diff-schema.ts create mode 100644 packages/core/src/schema/read-schema.ts diff --git a/packages/cli/src/commands/schema/diff.ts b/packages/cli/src/commands/schema/diff.ts new file mode 100644 index 0000000..7d3b4f5 --- /dev/null +++ b/packages/cli/src/commands/schema/diff.ts @@ -0,0 +1,246 @@ +import fs from "node:fs"; +import { Flags } from "@oclif/core"; +import { + readSchemaFromPermify, + diffSchema, + textDiff, + type SchemaEntityMap, + type SchemaDiffResult +} from "@permify-toolkit/core"; + +import { BaseCommand } from "../../base.js"; +import { loadSchemaFromConfig, validateSchemaFile } from "../../helpers.js"; + +export default class SchemaDiff extends BaseCommand { + static description = + "Preview what will change before pushing a schema update"; + + static args = {}; + + static flags = { + ...BaseCommand.baseFlags, + verbose: Flags.boolean({ + char: "v", + description: "Show raw unified text diff after structural summary", + default: false + }), + "exit-code": Flags.boolean({ + description: "Exit with code 1 if changes are detected (for CI)", + default: false + }), + source: Flags.string({ + description: + "Path to a .perm file to compare against (local-vs-local mode, skips remote)", + required: false + }) + }; + + async run() { + const { flags } = await this.parse(SchemaDiff); + + const { client, config } = await this.clientFromConfig(); + + let localDsl: string; + try { + localDsl = loadSchemaFromConfig(config.schema); + } catch (err: any) { + this.error(`Failed to load schema: ${err.message}`); + } + + const localEntities = parseEntitiesFromDsl(localDsl); + const tenantId = this.resolveTenant(flags, config); + + let remoteDsl: string; + let remoteEntities: SchemaEntityMap; + let remoteLabel: string; + + if (flags.source) { + const fullPath = validateSchemaFile(flags.source); + remoteDsl = fs.readFileSync(fullPath, "utf-8"); + remoteEntities = parseEntitiesFromDsl(remoteDsl); + remoteLabel = `source (${flags.source})`; + } else { + const remote = await readSchemaFromPermify({ tenantId, client }); + remoteDsl = remote.schema ?? ""; + remoteEntities = remote.entities; + remoteLabel = `remote (tenant: ${tenantId})`; + } + + const result = diffSchema(localEntities, remoteEntities); + + if (!result.hasChanges) { + this.log( + `\x1B[32m✔ Schema is up to date — no changes detected\x1B[0m (tenant: ${tenantId})` + ); + return; + } + + const isFirstTime = !flags.source && remoteDsl === ""; + this.renderStructuralDiff(result, tenantId, isFirstTime); + + if (flags.verbose) { + const localLabel = "local (permify.config.ts)"; + const diff = textDiff(localDsl, remoteDsl, localLabel, remoteLabel); + if (diff) { + this.log(""); + this.log(colorizeTextDiff(diff)); + } + } + + if (flags["exit-code"] && result.hasChanges) { + this.exit(1); + } + } + + private renderStructuralDiff( + result: SchemaDiffResult, + tenantId: string, + isFirstTime: boolean + ) { + this.log(`\x1B[1mSchema Diff\x1B[0m — tenant: ${tenantId}\n`); + + if (isFirstTime) { + this.log( + `\x1B[36mℹ No schema found on remote — showing full schema as additions\x1B[0m\n` + ); + } + + if ( + result.added.length || + result.removed.length || + result.modified.length + ) { + this.log("\x1B[1mEntities:\x1B[0m"); + } + + for (const e of result.added) { + this.log(` \x1B[32m+ ${e.name}\x1B[0m`); + this.renderEntityDetails(e.relations, e.permissions, "+"); + } + + for (const e of result.removed) { + this.log(` \x1B[31m- ${e.name}\x1B[0m`); + this.renderEntityDetails(e.relations, e.permissions, "-"); + } + + for (const e of result.modified) { + this.log(` \x1B[33m~ ${e.name}\x1B[0m`); + + if ( + e.relations.added.length || + e.relations.removed.length || + e.relations.changed.length + ) { + this.log(` \x1B[2mRelations:\x1B[0m`); + for (const r of e.relations.added) { + this.log(` \x1B[32m+ ${r}\x1B[0m`); + } + for (const r of e.relations.removed) { + this.log(` \x1B[31m- ${r}\x1B[0m`); + } + for (const r of e.relations.changed) { + this.log(` \x1B[33m~ ${r}\x1B[0m`); + } + } + + if ( + e.permissions.added.length || + e.permissions.removed.length || + e.permissions.changed.length + ) { + this.log(` \x1B[2mPermissions:\x1B[0m`); + for (const p of e.permissions.added) { + this.log(` \x1B[32m+ ${p}\x1B[0m`); + } + for (const p of e.permissions.removed) { + this.log(` \x1B[31m- ${p}\x1B[0m`); + } + for (const p of e.permissions.changed) { + this.log(` \x1B[33m~ ${p}\x1B[0m`); + } + } + } + + this.log(""); + const parts: string[] = []; + if (result.added.length) parts.push(`${result.added.length} added`); + if (result.removed.length) parts.push(`${result.removed.length} removed`); + if (result.modified.length) + parts.push(`${result.modified.length} modified`); + this.log( + `\x1B[2mSummary: ${parts.join(", ")} ${result.added.length + result.removed.length + result.modified.length === 1 ? "entity" : "entities"}\x1B[0m` + ); + } + + private renderEntityDetails( + relations: Record, + permissions: Record, + prefix: string + ) { + const color = prefix === "+" ? "\x1B[32m" : "\x1B[31m"; + const relKeys = Object.keys(relations); + const permKeys = Object.keys(permissions); + if (relKeys.length) { + this.log(` \x1B[2mRelations:\x1B[0m`); + for (const r of relKeys) { + this.log(` ${color}${prefix} ${r}\x1B[0m`); + } + } + if (permKeys.length) { + this.log(` \x1B[2mPermissions:\x1B[0m`); + for (const p of permKeys) { + this.log(` ${color}${prefix} ${p}\x1B[0m`); + } + } + } +} + +/** + * Parses a DSL string into a flat SchemaEntityMap. + * Extracts entity names, relation names, and permission names. + */ +function parseEntitiesFromDsl(dsl: string): SchemaEntityMap { + const entities: SchemaEntityMap = {}; + const entityRegex = /entity\s+(\w+)\s*{([^}]*)}/gs; + + for (const match of dsl.matchAll(entityRegex)) { + const name = match[1]; + const body = stripComments(match[2]); + + const relations: Record = {}; + for (const m of body.matchAll(/relation\s+(\w+)\s+(.*)/g)) { + relations[m[1]] = m[2].trim(); + } + + const permissions: Record = {}; + for (const m of body.matchAll(/permission\s+(\w+)\s*=\s*(.*)/g)) { + permissions[m[1]] = m[2].trim(); + } + + entities[name] = { relations, permissions }; + } + + return entities; +} + +function stripComments(text: string): string { + return text + .split("\n") + .map((line) => line.replace(/\/\/.*$/, "")) + .join("\n"); +} + +function colorizeTextDiff(diff: string): string { + return diff + .split("\n") + .map((line) => { + if (line.startsWith("+++") || line.startsWith("---")) { + return `\x1B[1m${line}\x1B[0m`; + } + if (line.startsWith("+")) return `\x1B[32m${line}\x1B[0m`; + if (line.startsWith("-")) return `\x1B[31m${line}\x1B[0m`; + if (line.startsWith("@@")) return `\x1B[36m${line}\x1B[0m`; + return line; + }) + .join("\n"); +} diff --git a/packages/core/src/public-api.ts b/packages/core/src/public-api.ts index 46e5966..4e15e53 100644 --- a/packages/core/src/public-api.ts +++ b/packages/core/src/public-api.ts @@ -17,6 +17,21 @@ export { relationsOf } from "./schema/helpers.js"; export { writeSchemaToPermify } from "./schema/write-schema.js"; +export { + readSchemaFromPermify, + type ReadSchemaParams, + type ReadSchemaResult, + type SchemaEntityMap +} from "./schema/read-schema.js"; + +export { + diffSchema, + textDiff, + type SchemaDiffResult, + type EntityDiff, + type ModifiedEntityDiff +} from "./schema/diff-schema.js"; + export { getSchemaWarnings } from "./schema/validate.js"; export { diff --git a/packages/core/src/schema/diff-schema.ts b/packages/core/src/schema/diff-schema.ts new file mode 100644 index 0000000..8018a53 --- /dev/null +++ b/packages/core/src/schema/diff-schema.ts @@ -0,0 +1,247 @@ +import type { SchemaEntityMap } from "./read-schema.js"; + +export interface EntityDiff { + name: string; + relations: Record; + permissions: Record; +} + +export interface ModifiedEntityDiff { + name: string; + relations: { added: string[]; removed: string[]; changed: string[] }; + permissions: { added: string[]; removed: string[]; changed: string[] }; +} + +export interface SchemaDiffResult { + hasChanges: boolean; + added: EntityDiff[]; + removed: EntityDiff[]; + modified: ModifiedEntityDiff[]; +} + +/** + * Computes a structural diff between two schema entity maps. + * @param local - The local (source) schema entities + * @param remote - The remote (target) schema entities + */ +export function diffSchema( + local: SchemaEntityMap, + remote: SchemaEntityMap +): SchemaDiffResult { + const localNames = new Set(Object.keys(local)); + const remoteNames = new Set(Object.keys(remote)); + + const added: EntityDiff[] = []; + const removed: EntityDiff[] = []; + const modified: ModifiedEntityDiff[] = []; + + for (const name of localNames) { + if (!remoteNames.has(name)) { + added.push({ + name, + relations: local[name].relations, + permissions: local[name].permissions + }); + } + } + + for (const name of remoteNames) { + if (!localNames.has(name)) { + removed.push({ + name, + relations: remote[name].relations, + permissions: remote[name].permissions + }); + } + } + + for (const name of localNames) { + if (!remoteNames.has(name)) continue; + + const l = local[name]; + const r = remote[name]; + + const lRelKeys = Object.keys(l.relations); + const rRelKeys = Object.keys(r.relations); + const relAdded = lRelKeys.filter((k) => !(k in r.relations)); + const relRemoved = rRelKeys.filter((k) => !(k in l.relations)); + const relChanged = lRelKeys.filter( + (k) => k in r.relations && l.relations[k] !== r.relations[k] + ); + + const lPermKeys = Object.keys(l.permissions); + const rPermKeys = Object.keys(r.permissions); + const permAdded = lPermKeys.filter((k) => !(k in r.permissions)); + const permRemoved = rPermKeys.filter((k) => !(k in l.permissions)); + const permChanged = lPermKeys.filter( + (k) => k in r.permissions && l.permissions[k] !== r.permissions[k] + ); + + if ( + relAdded.length || + relRemoved.length || + relChanged.length || + permAdded.length || + permRemoved.length || + permChanged.length + ) { + modified.push({ + name, + relations: { + added: relAdded, + removed: relRemoved, + changed: relChanged + }, + permissions: { + added: permAdded, + removed: permRemoved, + changed: permChanged + } + }); + } + } + + const hasChanges = + added.length > 0 || removed.length > 0 || modified.length > 0; + + return { hasChanges, added, removed, modified }; +} + +/** + * Generates a unified text diff between two DSL strings. + * Returns an empty string if both are identical. + */ +export function textDiff( + localDsl: string, + remoteDsl: string, + localLabel: string, + remoteLabel: string +): string { + const localLines = localDsl.split("\n"); + const remoteLines = remoteDsl.split("\n"); + + if (localDsl === remoteDsl) return ""; + + const lcs = computeLcs(remoteLines, localLines); + const hunks = buildHunks(remoteLines, localLines, lcs, 3); + + if (hunks.length === 0) return ""; + + const output: string[] = [`--- ${remoteLabel}`, `+++ ${localLabel}`]; + + for (const hunk of hunks) { + output.push(hunk.header); + output.push(...hunk.lines); + } + + return output.join("\n"); +} + +interface Hunk { + header: string; + lines: string[]; +} + +function computeLcs(a: string[], b: string[]): number[][] { + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => + Array.from({ length: n + 1 }).fill(0) + ); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + + return dp; +} + +function buildHunks( + a: string[], + b: string[], + dp: number[][], + context: number +): Hunk[] { + const edits: Array<{ + type: " " | "-" | "+"; + aIdx: number; + bIdx: number; + text: string; + }> = []; + + let i = a.length; + let j = b.length; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { + edits.unshift({ type: " ", aIdx: i - 1, bIdx: j - 1, text: a[i - 1] }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + edits.unshift({ type: "+", aIdx: i, bIdx: j - 1, text: b[j - 1] }); + j--; + } else { + edits.unshift({ type: "-", aIdx: i - 1, bIdx: j, text: a[i - 1] }); + i--; + } + } + + const changeIndices = edits + .map((e, idx) => (e.type !== " " ? idx : -1)) + .filter((idx) => idx >= 0); + + if (changeIndices.length === 0) return []; + + const hunks: Hunk[] = []; + let hunkStart = Math.max(0, changeIndices[0] - context); + let hunkEnd = Math.min(edits.length - 1, changeIndices[0] + context); + + for (let ci = 1; ci < changeIndices.length; ci++) { + const nextStart = Math.max(0, changeIndices[ci] - context); + const nextEnd = Math.min(edits.length - 1, changeIndices[ci] + context); + + if (nextStart <= hunkEnd + 1) { + hunkEnd = nextEnd; + } else { + hunks.push(buildSingleHunk(edits, hunkStart, hunkEnd)); + hunkStart = nextStart; + hunkEnd = nextEnd; + } + } + + hunks.push(buildSingleHunk(edits, hunkStart, hunkEnd)); + return hunks; +} + +function buildSingleHunk( + edits: Array<{ type: " " | "-" | "+"; text: string }>, + start: number, + end: number +): Hunk { + let aStart = 1; + let bStart = 1; + let aCount = 0; + let bCount = 0; + + for (let i = 0; i < start; i++) { + if (edits[i].type !== "+") aStart++; + if (edits[i].type !== "-") bStart++; + } + + const lines: string[] = []; + for (let i = start; i <= end; i++) { + lines.push(`${edits[i].type}${edits[i].text}`); + if (edits[i].type !== "+") aCount++; + if (edits[i].type !== "-") bCount++; + } + + return { + header: `@@ -${aStart},${aCount} +${bStart},${bCount} @@`, + lines + }; +} diff --git a/packages/core/src/schema/read-schema.ts b/packages/core/src/schema/read-schema.ts new file mode 100644 index 0000000..e4bec48 --- /dev/null +++ b/packages/core/src/schema/read-schema.ts @@ -0,0 +1,162 @@ +import { createPermifyClient } from "../client/index.js"; + +export interface ReadSchemaParams { + tenantId: string; + client?: any; + endpoint?: string; +} + +export interface SchemaEntityMap { + [entityName: string]: { + relations: Record; + permissions: Record; + }; +} + +export interface ReadSchemaResult { + /** Raw schema DSL string, or null if no schema exists on the server */ + schema: string | null; + /** Flat entity map for diffing */ + entities: SchemaEntityMap; +} + +/** + * Reads the current schema from a Permify server for a given tenant. + * Returns a flat entity map and a reconstructed DSL string. + */ +export async function readSchemaFromPermify( + params: ReadSchemaParams +): Promise { + if (!params.client && !params.endpoint) { + throw new Error("Either endpoint or client must be provided"); + } + if (!params.tenantId) { + throw new Error("Tenant ID is required"); + } + + const client = + params.client || createPermifyClient({ endpoint: params.endpoint! }); + + let schemaVersion: string; + try { + const listResponse = await client.schema.list({ + tenantId: params.tenantId, + pageSize: 1 + }); + if (!listResponse.head) { + return { schema: null, entities: {} }; + } + schemaVersion = listResponse.head; + } catch { + return { schema: null, entities: {} }; + } + + const response = await client.schema.read({ + tenantId: params.tenantId, + metadata: { schemaVersion } + }); + + if (!response.schema?.entityDefinitions) { + return { schema: null, entities: {} }; + } + + const entities: SchemaEntityMap = {}; + const dslParts: string[] = []; + + for (const [name, def] of Object.entries( + response.schema.entityDefinitions + )) { + const relations: Record = {}; + for (const [relName, relDef] of Object.entries(def.relations || {})) { + relations[relName] = extractRelationTypes(relDef); + } + + const permissions: Record = {}; + for (const [permName, permDef] of Object.entries( + def.permissions || {} + )) { + permissions[permName] = reconstructPermissionExpr(permDef); + } + + entities[name] = { relations, permissions }; + + dslParts.push(reconstructEntityDsl(name, def)); + } + + return { + schema: dslParts.join("\n\n"), + entities + }; +} + +function reconstructEntityDsl(name: string, def: any): string { + const lines: string[] = [`entity ${name} {`]; + + if (def.relations) { + for (const [relName, relDef] of Object.entries(def.relations)) { + const types = extractRelationTypes(relDef); + lines.push(` relation ${relName} ${types}`); + } + } + + if (def.permissions) { + for (const [permName, permDef] of Object.entries(def.permissions)) { + const expr = reconstructPermissionExpr(permDef); + lines.push(` permission ${permName} = ${expr}`); + } + } + + lines.push("}"); + return lines.join("\n"); +} + +function extractRelationTypes(relDef: any): string { + if (!relDef.relationReferences?.length) return "@unknown"; + + return relDef.relationReferences + .map((ref: any) => { + const base = `@${ref.type}`; + return ref.relation ? `${base}#${ref.relation}` : base; + }) + .join(" or "); +} + +function reconstructPermissionExpr(permDef: any): string { + if (!permDef.child) return "unknown"; + return reconstructNode(permDef.child); +} + +function reconstructNode(node: any): string { + if (!node) return "unknown"; + + if (node.leaf) { + const leaf = node.leaf; + if (leaf.computedUserSet) { + return leaf.computedUserSet.relation || "unknown"; + } + if (leaf.tupleToUserSet) { + const ttu = leaf.tupleToUserSet; + return `${ttu.tupleSet?.relation || "unknown"}.${ttu.computed?.relation || "unknown"}`; + } + if (leaf.computedAttribute) { + return leaf.computedAttribute.name || "unknown"; + } + } + + if (node.rewrite) { + const rewrite = node.rewrite; + const children = rewrite.children || []; + + if (rewrite.rewriteOperation === "OPERATION_UNION") { + return children.map(reconstructNode).join(" or "); + } + if (rewrite.rewriteOperation === "OPERATION_INTERSECTION") { + return children.map(reconstructNode).join(" and "); + } + if (rewrite.rewriteOperation === "OPERATION_EXCLUSION") { + return children.map(reconstructNode).join(" not "); + } + } + + return "unknown"; +} From 6bc570415473d5fe10980d318b80360f5fc1b02d Mon Sep 17 00:00:00 2001 From: thisisnkc Date: Tue, 7 Apr 2026 13:48:56 +0530 Subject: [PATCH 2/4] feat: add diffSchema and readSchema tests for schema comparison and validation --- packages/core/tests/diff-schema.spec.ts | 288 ++++++++++++++++++++++++ packages/core/tests/read-schema.spec.ts | 245 ++++++++++++++++++++ 2 files changed, 533 insertions(+) create mode 100644 packages/core/tests/diff-schema.spec.ts create mode 100644 packages/core/tests/read-schema.spec.ts diff --git a/packages/core/tests/diff-schema.spec.ts b/packages/core/tests/diff-schema.spec.ts new file mode 100644 index 0000000..6ee369f --- /dev/null +++ b/packages/core/tests/diff-schema.spec.ts @@ -0,0 +1,288 @@ +import "@japa/assert"; + +import { test } from "@japa/runner"; + +import { diffSchema, textDiff } from "../src/schema/diff-schema.js"; +import type { SchemaEntityMap } from "../src/schema/read-schema.js"; + +// --- helpers --- + +function emptyMap(): SchemaEntityMap { + return {}; +} + +function singleEntity( + name: string, + relations: Record = {}, + permissions: Record = {} +): SchemaEntityMap { + return { [name]: { relations, permissions } }; +} + +// --- diffSchema --- + +test.group("diffSchema - identical schemas", () => { + test("returns no changes for empty schemas", ({ assert }) => { + const result = diffSchema(emptyMap(), emptyMap()); + assert.isFalse(result.hasChanges); + assert.lengthOf(result.added, 0); + assert.lengthOf(result.removed, 0); + assert.lengthOf(result.modified, 0); + }); + + test("returns no changes when schemas match", ({ assert }) => { + const map: SchemaEntityMap = { + user: { relations: {}, permissions: {} }, + document: { + relations: { owner: "@user" }, + permissions: { edit: "owner", view: "owner" } + } + }; + const result = diffSchema(map, map); + assert.isFalse(result.hasChanges); + }); +}); + +test.group("diffSchema - added entities", () => { + test("detects a new entity in local", ({ assert }) => { + const local = singleEntity( + "document", + { owner: "@user" }, + { edit: "owner" } + ); + const result = diffSchema(local, emptyMap()); + + assert.isTrue(result.hasChanges); + assert.lengthOf(result.added, 1); + assert.equal(result.added[0].name, "document"); + assert.deepEqual(result.added[0].relations, { owner: "@user" }); + assert.deepEqual(result.added[0].permissions, { edit: "owner" }); + assert.lengthOf(result.removed, 0); + assert.lengthOf(result.modified, 0); + }); + + test("detects multiple added entities", ({ assert }) => { + const local: SchemaEntityMap = { + user: { relations: {}, permissions: {} }, + document: { relations: { owner: "@user" }, permissions: {} } + }; + const result = diffSchema(local, emptyMap()); + + assert.isTrue(result.hasChanges); + assert.lengthOf(result.added, 2); + }); +}); + +test.group("diffSchema - removed entities", () => { + test("detects entity removed from local", ({ assert }) => { + const remote = singleEntity( + "team", + { member: "@user" }, + { view: "member" } + ); + const result = diffSchema(emptyMap(), remote); + + assert.isTrue(result.hasChanges); + assert.lengthOf(result.removed, 1); + assert.equal(result.removed[0].name, "team"); + assert.lengthOf(result.added, 0); + }); +}); + +test.group("diffSchema - modified entities", () => { + test("detects added relation", ({ assert }) => { + const local: SchemaEntityMap = { + document: { + relations: { owner: "@user", editor: "@user" }, + permissions: { edit: "owner" } + } + }; + const remote: SchemaEntityMap = { + document: { + relations: { owner: "@user" }, + permissions: { edit: "owner" } + } + }; + const result = diffSchema(local, remote); + + assert.isTrue(result.hasChanges); + assert.lengthOf(result.modified, 1); + assert.deepEqual(result.modified[0].relations.added, ["editor"]); + assert.lengthOf(result.modified[0].relations.removed, 0); + assert.lengthOf(result.modified[0].relations.changed, 0); + }); + + test("detects removed relation", ({ assert }) => { + const local: SchemaEntityMap = { + document: { + relations: { owner: "@user" }, + permissions: {} + } + }; + const remote: SchemaEntityMap = { + document: { + relations: { owner: "@user", viewer: "@user" }, + permissions: {} + } + }; + const result = diffSchema(local, remote); + + assert.isTrue(result.hasChanges); + assert.deepEqual(result.modified[0].relations.removed, ["viewer"]); + }); + + test("detects changed permission expression", ({ assert }) => { + const local: SchemaEntityMap = { + document: { + relations: { owner: "@user" }, + permissions: { view: "owner or editor" } + } + }; + const remote: SchemaEntityMap = { + document: { + relations: { owner: "@user" }, + permissions: { view: "owner" } + } + }; + const result = diffSchema(local, remote); + + assert.isTrue(result.hasChanges); + assert.lengthOf(result.modified, 1); + assert.deepEqual(result.modified[0].permissions.changed, ["view"]); + assert.lengthOf(result.modified[0].permissions.added, 0); + assert.lengthOf(result.modified[0].permissions.removed, 0); + }); + + test("detects changed relation target", ({ assert }) => { + const local: SchemaEntityMap = { + document: { + relations: { parent: "@folder" }, + permissions: {} + } + }; + const remote: SchemaEntityMap = { + document: { + relations: { parent: "@organization" }, + permissions: {} + } + }; + const result = diffSchema(local, remote); + + assert.isTrue(result.hasChanges); + assert.deepEqual(result.modified[0].relations.changed, ["parent"]); + }); + + test("does not flag entity as modified when contents match", ({ assert }) => { + const map: SchemaEntityMap = { + document: { + relations: { owner: "@user" }, + permissions: { edit: "owner" } + } + }; + const result = diffSchema(map, { ...map }); + assert.isFalse(result.hasChanges); + assert.lengthOf(result.modified, 0); + }); +}); + +test.group("diffSchema - mixed changes", () => { + test("handles added, removed, and modified entities together", ({ + assert + }) => { + const local: SchemaEntityMap = { + user: { relations: { manager: "@user" }, permissions: {} }, + document: { + relations: { owner: "@user", viewer: "@user" }, + permissions: { edit: "owner" } + }, + school: { + relations: { teacher: "@user" }, + permissions: { teach: "teacher" } + } + }; + const remote: SchemaEntityMap = { + user: { relations: {}, permissions: {} }, + document: { + relations: { owner: "@user", parent: "@organization" }, + permissions: { edit: "owner" } + }, + organization: { + relations: { member: "@user" }, + permissions: { view: "member" } + } + }; + const result = diffSchema(local, remote); + + assert.isTrue(result.hasChanges); + assert.lengthOf(result.added, 1); + assert.equal(result.added[0].name, "school"); + assert.lengthOf(result.removed, 1); + assert.equal(result.removed[0].name, "organization"); + assert.lengthOf(result.modified, 2); + + const userMod = result.modified.find((m) => m.name === "user")!; + assert.deepEqual(userMod.relations.added, ["manager"]); + + const docMod = result.modified.find((m) => m.name === "document")!; + assert.deepEqual(docMod.relations.added, ["viewer"]); + assert.deepEqual(docMod.relations.removed, ["parent"]); + }); +}); + +// --- textDiff --- + +test.group("textDiff - identical content", () => { + test("returns empty string for identical inputs", ({ assert }) => { + const dsl = + "entity user {}\n\nentity document {\n relation owner @user\n}"; + const result = textDiff(dsl, dsl, "local", "remote"); + assert.equal(result, ""); + }); +}); + +test.group("textDiff - additions", () => { + test("shows added lines with + prefix", ({ assert }) => { + const remote = "entity user {}"; + const local = + "entity user {}\n\nentity document {\n relation owner @user\n}"; + const result = textDiff(local, remote, "local", "remote"); + + assert.include(result, "--- remote"); + assert.include(result, "+++ local"); + assert.include(result, "+entity document {"); + assert.include(result, "+ relation owner @user"); + }); +}); + +test.group("textDiff - removals", () => { + test("shows removed lines with - prefix", ({ assert }) => { + const remote = + "entity user {}\n\nentity team {\n relation member @user\n}"; + const local = "entity user {}"; + const result = textDiff(local, remote, "local", "remote"); + + assert.include(result, "-entity team {"); + assert.include(result, "- relation member @user"); + }); +}); + +test.group("textDiff - modifications", () => { + test("shows changed lines as removal + addition", ({ assert }) => { + const remote = "entity document {\n permission view = owner\n}"; + const local = "entity document {\n permission view = owner or editor\n}"; + const result = textDiff(local, remote, "local", "remote"); + + assert.include(result, "- permission view = owner"); + assert.include(result, "+ permission view = owner or editor"); + }); +}); + +test.group("textDiff - hunk headers", () => { + test("includes @@ hunk headers", ({ assert }) => { + const remote = "entity user {}"; + const local = "entity user {}\nentity doc {}"; + const result = textDiff(local, remote, "local", "remote"); + + assert.match(result, /@@\s+-\d+,\d+\s+\+\d+,\d+\s+@@/); + }); +}); diff --git a/packages/core/tests/read-schema.spec.ts b/packages/core/tests/read-schema.spec.ts new file mode 100644 index 0000000..00469eb --- /dev/null +++ b/packages/core/tests/read-schema.spec.ts @@ -0,0 +1,245 @@ +import "@japa/assert"; + +import { test } from "@japa/runner"; + +import { readSchemaFromPermify } from "../src/schema/read-schema.js"; + +// --- mock client helpers --- + +function mockClient(opts: { + head?: string; + entityDefinitions?: Record; + listError?: boolean; +}) { + return { + schema: { + list: async () => { + if (opts.listError) throw new Error("connection refused"); + return { head: opts.head ?? "", schemas: [], continuousToken: "" }; + }, + read: async () => ({ + schema: { + entityDefinitions: opts.entityDefinitions ?? {}, + ruleDefinitions: {}, + references: {} + } + }) + } + }; +} + +test.group("readSchemaFromPermify - validation", () => { + test("throws when neither client nor endpoint provided", async ({ + assert + }) => { + try { + await readSchemaFromPermify({ tenantId: "t1" }); + assert.fail("Should have thrown"); + } catch (err: unknown) { + assert.include((err as Error).message, "Either endpoint or client"); + } + }); + + test("throws when tenantId is empty", async ({ assert }) => { + const client = mockClient({}); + try { + await readSchemaFromPermify({ tenantId: "", client }); + assert.fail("Should have thrown"); + } catch (err: unknown) { + assert.include((err as Error).message, "Tenant ID is required"); + } + }); +}); + +test.group("readSchemaFromPermify - no schema on server", () => { + test("returns null schema when head is empty", async ({ assert }) => { + const client = mockClient({ head: "" }); + const result = await readSchemaFromPermify({ tenantId: "t1", client }); + + assert.isNull(result.schema); + assert.deepEqual(result.entities, {}); + }); + + test("returns null schema when list throws", async ({ assert }) => { + const client = mockClient({ listError: true }); + const result = await readSchemaFromPermify({ tenantId: "t1", client }); + + assert.isNull(result.schema); + assert.deepEqual(result.entities, {}); + }); +}); + +test.group("readSchemaFromPermify - successful read", () => { + test("extracts entity names and relations", async ({ assert }) => { + const client = mockClient({ + head: "v1", + entityDefinitions: { + user: { relations: {}, permissions: {} }, + document: { + relations: { + owner: { + relationReferences: [{ type: "user", relation: "" }] + } + }, + permissions: { + edit: { + child: { + leaf: { computedUserSet: { relation: "owner" } } + } + } + } + } + } + }); + + const result = await readSchemaFromPermify({ tenantId: "t1", client }); + + assert.isNotNull(result.schema); + assert.property(result.entities, "user"); + assert.property(result.entities, "document"); + assert.deepEqual(result.entities.document.relations, { owner: "@user" }); + assert.deepEqual(result.entities.document.permissions, { edit: "owner" }); + }); + + test("handles multiple relation targets", async ({ assert }) => { + const client = mockClient({ + head: "v1", + entityDefinitions: { + document: { + relations: { + viewer: { + relationReferences: [ + { type: "user", relation: "" }, + { type: "organization", relation: "member" } + ] + } + }, + permissions: {} + } + } + }); + + const result = await readSchemaFromPermify({ tenantId: "t1", client }); + assert.equal( + result.entities.document.relations.viewer, + "@user or @organization#member" + ); + }); + + test("reconstructs DSL string", async ({ assert }) => { + const client = mockClient({ + head: "v1", + entityDefinitions: { + user: { relations: {}, permissions: {} }, + document: { + relations: { + owner: { + relationReferences: [{ type: "user", relation: "" }] + } + }, + permissions: { + edit: { + child: { + leaf: { computedUserSet: { relation: "owner" } } + } + } + } + } + } + }); + + const result = await readSchemaFromPermify({ tenantId: "t1", client }); + + assert.include(result.schema!, "entity user {"); + assert.include(result.schema!, "entity document {"); + assert.include(result.schema!, "relation owner @user"); + assert.include(result.schema!, "permission edit = owner"); + }); +}); + +test.group( + "readSchemaFromPermify - permission expression reconstruction", + () => { + test("reconstructs union (or) expression", async ({ assert }) => { + const client = mockClient({ + head: "v1", + entityDefinitions: { + doc: { + relations: {}, + permissions: { + view: { + child: { + rewrite: { + rewriteOperation: "OPERATION_UNION", + children: [ + { leaf: { computedUserSet: { relation: "owner" } } }, + { leaf: { computedUserSet: { relation: "viewer" } } } + ] + } + } + } + } + } + } + }); + + const result = await readSchemaFromPermify({ tenantId: "t1", client }); + assert.equal(result.entities.doc.permissions.view, "owner or viewer"); + }); + + test("reconstructs intersection (and) expression", async ({ assert }) => { + const client = mockClient({ + head: "v1", + entityDefinitions: { + doc: { + relations: {}, + permissions: { + view: { + child: { + rewrite: { + rewriteOperation: "OPERATION_INTERSECTION", + children: [ + { leaf: { computedUserSet: { relation: "owner" } } }, + { leaf: { computedUserSet: { relation: "member" } } } + ] + } + } + } + } + } + } + }); + + const result = await readSchemaFromPermify({ tenantId: "t1", client }); + assert.equal(result.entities.doc.permissions.view, "owner and member"); + }); + + test("reconstructs tuple-to-userset (dot traversal) expression", async ({ + assert + }) => { + const client = mockClient({ + head: "v1", + entityDefinitions: { + doc: { + relations: {}, + permissions: { + view: { + child: { + leaf: { + tupleToUserSet: { + tupleSet: { relation: "parent" }, + computed: { relation: "view" } + } + } + } + } + } + } + } + }); + + const result = await readSchemaFromPermify({ tenantId: "t1", client }); + assert.equal(result.entities.doc.permissions.view, "parent.view"); + }); + } +); From 396fc00651a155cf3e40e32bad3604087b96d7e5 Mon Sep 17 00:00:00 2001 From: thisisnkc Date: Tue, 7 Apr 2026 13:56:42 +0530 Subject: [PATCH 3/4] feat: add --source and --exit-code flags aliases and tests for each flag to SchemaDiff command --- packages/cli/src/commands/schema/diff.ts | 2 + packages/cli/tests/schema-diff.spec.ts | 512 +++++++++++++++++++++++ 2 files changed, 514 insertions(+) create mode 100644 packages/cli/tests/schema-diff.spec.ts diff --git a/packages/cli/src/commands/schema/diff.ts b/packages/cli/src/commands/schema/diff.ts index 7d3b4f5..f3328ed 100644 --- a/packages/cli/src/commands/schema/diff.ts +++ b/packages/cli/src/commands/schema/diff.ts @@ -25,10 +25,12 @@ export default class SchemaDiff extends BaseCommand { default: false }), "exit-code": Flags.boolean({ + char: "e", description: "Exit with code 1 if changes are detected (for CI)", default: false }), source: Flags.string({ + char: "s", description: "Path to a .perm file to compare against (local-vs-local mode, skips remote)", required: false diff --git a/packages/cli/tests/schema-diff.spec.ts b/packages/cli/tests/schema-diff.spec.ts new file mode 100644 index 0000000..c8387c7 --- /dev/null +++ b/packages/cli/tests/schema-diff.spec.ts @@ -0,0 +1,512 @@ +import "@japa/assert"; +import "@japa/file-system"; + +import { test } from "@japa/runner"; + +import { runCommand, stripAnsi } from "./helpers.js"; +import SchemaDiff from "../src/commands/schema/diff.js"; + +const localSchema = `{ + ast: {}, + compile: () => "entity user {}\\nentity document {\\n relation owner @user\\n permission edit = owner\\n}", + validate: () => {} +}`; + +const identicalSourceSchema = + "entity user {}\nentity document {\n relation owner @user\n permission edit = owner\n}"; + +const differentSourceSchema = + "entity user {}\nentity team {\n relation member @user\n permission view = member\n}"; + +function captureOutput(): { logs: string[]; restore: () => void } { + const logs: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + (process.stdout as any).write = (chunk: unknown) => { + logs.push(typeof chunk === "string" ? chunk : String(chunk)); + return true; + }; + return { + logs, + restore: () => { + (process.stdout as any).write = origWrite; + } + }; +} + +// --- --source flag --- + +test.group("Schema Diff Command - --source flag", () => { + test("should compare local config against source .perm file", async ({ + assert, + fs + }) => { + await fs.create("source.perm", differentSourceSchema); + const sourcePath = `${fs.basePath}/source.perm`; + + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand(SchemaDiff as any, ["--source", sourcePath], { + cwd: fs.basePath + }); + } finally { + output.restore(); + } + + const text = stripAnsi(output.logs.join("")); + assert.include(text, "Schema Diff"); + assert.include(text, "document"); + }); + + test("should fail if source file does not exist", async ({ assert, fs }) => { + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + try { + await runCommand( + SchemaDiff as any, + ["--source", "/tmp/nonexistent.perm"], + { cwd: fs.basePath } + ); + assert.fail("Command should have failed"); + } catch (error: unknown) { + const msg = stripAnsi((error as Error).message); + assert.include(msg, "not found"); + } + }); + + test("should fail if source file is not a .perm file", async ({ + assert, + fs + }) => { + await fs.create("source.txt", "entity user {}"); + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.txt`], + { cwd: fs.basePath } + ); + assert.fail("Command should have failed"); + } catch (error: unknown) { + const msg = stripAnsi((error as Error).message); + assert.include(msg, ".perm"); + } + }); + + test("should accept -s alias for --source", async ({ assert, fs }) => { + await fs.create("source.perm", differentSourceSchema); + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand( + SchemaDiff as any, + ["-s", `${fs.basePath}/source.perm`], + { cwd: fs.basePath } + ); + } finally { + output.restore(); + } + + const text = stripAnsi(output.logs.join("")); + assert.include(text, "Schema Diff"); + }); +}); + +// --- --verbose flag --- + +test.group("Schema Diff Command - --verbose flag", () => { + test("should show unified text diff when --verbose is set", async ({ + assert, + fs + }) => { + await fs.create("source.perm", differentSourceSchema); + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.perm`, "--verbose"], + { cwd: fs.basePath } + ); + } finally { + output.restore(); + } + + const text = stripAnsi(output.logs.join("")); + assert.include(text, "---"); + assert.include(text, "+++"); + assert.include(text, "@@"); + }); + + test("should not show text diff without --verbose", async ({ + assert, + fs + }) => { + await fs.create("source.perm", differentSourceSchema); + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.perm`], + { cwd: fs.basePath } + ); + } finally { + output.restore(); + } + + const text = stripAnsi(output.logs.join("")); + assert.notInclude(text, "@@"); + }); + + test("should accept -v alias for --verbose", async ({ assert, fs }) => { + await fs.create("source.perm", differentSourceSchema); + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.perm`, "-v"], + { cwd: fs.basePath } + ); + } finally { + output.restore(); + } + + const text = stripAnsi(output.logs.join("")); + assert.include(text, "@@"); + }); +}); + +// --- --exit-code flag --- + +test.group("Schema Diff Command - --exit-code flag", () => { + test("should exit with code 1 when changes detected and --exit-code set", async ({ + assert, + fs + }) => { + await fs.create("source.perm", differentSourceSchema); + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.perm`, "--exit-code"], + { cwd: fs.basePath } + ); + assert.fail("Command should have exited with code 1"); + } catch (error: any) { + // oclif wraps this.exit(1) as an error with oclif.exit = 1 + assert.equal(error.oclif?.exit, 1); + } finally { + output.restore(); + } + }); + + test("should exit with code 0 when no changes and --exit-code set", async ({ + assert, + fs + }) => { + await fs.create("source.perm", identicalSourceSchema); + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.perm`, "--exit-code"], + { cwd: fs.basePath } + ); + // Should not throw — exit code 0 + assert.isTrue(true); + } finally { + output.restore(); + } + }); + + test("should accept -e alias for --exit-code", async ({ assert, fs }) => { + await fs.create("source.perm", differentSourceSchema); + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.perm`, "-e"], + { cwd: fs.basePath } + ); + assert.fail("Command should have exited with code 1"); + } catch (error: any) { + assert.equal(error.oclif?.exit, 1); + } finally { + output.restore(); + } + }); +}); + +// --- --tenant flag --- + +test.group("Schema Diff Command - --tenant flag", () => { + test("should fail if no tenant is provided (flag or config)", async ({ + assert, + fs + }) => { + await fs.create("source.perm", differentSourceSchema); + await fs.create( + "permify.config.ts", + `export default { + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.perm`], + { cwd: fs.basePath } + ); + assert.fail("Command should have failed"); + } catch (error: unknown) { + const msg = stripAnsi((error as Error).message); + assert.include(msg, "Tenant ID is required"); + } + }); + + test("should resolve tenant from config when --tenant flag is not provided", async ({ + assert, + fs + }) => { + await fs.create("source.perm", identicalSourceSchema); + await fs.create( + "permify.config.ts", + `export default { + tenant: "config-tenant", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.perm`], + { cwd: fs.basePath } + ); + } finally { + output.restore(); + } + + const text = stripAnsi(output.logs.join("")); + assert.include(text, "config-tenant"); + }); + + test("should prefer --tenant flag over config tenant", async ({ + assert, + fs + }) => { + await fs.create("source.perm", identicalSourceSchema); + await fs.create( + "permify.config.ts", + `export default { + tenant: "config-tenant", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.perm`, "--tenant", "flag-tenant"], + { cwd: fs.basePath } + ); + } finally { + output.restore(); + } + + const text = stripAnsi(output.logs.join("")); + assert.include(text, "flag-tenant"); + assert.notInclude(text, "config-tenant"); + }); +}); + +// --- no changes --- + +test.group("Schema Diff Command - no changes", () => { + test("should show up-to-date message when schemas are identical", async ({ + assert, + fs + }) => { + await fs.create("source.perm", identicalSourceSchema); + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.perm`], + { cwd: fs.basePath } + ); + } finally { + output.restore(); + } + + const text = stripAnsi(output.logs.join("")); + assert.include(text, "up to date"); + assert.include(text, "no changes detected"); + }); +}); + +// --- structural output --- + +test.group("Schema Diff Command - structural output", () => { + test("should show added, removed, and modified entities", async ({ + assert, + fs + }) => { + await fs.create("source.perm", differentSourceSchema); + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.perm`], + { cwd: fs.basePath } + ); + } finally { + output.restore(); + } + + const text = stripAnsi(output.logs.join("")); + assert.include(text, "+ document"); + assert.include(text, "- team"); + assert.include(text, "Summary:"); + }); + + test("should show changed permissions when expression differs", async ({ + assert, + fs + }) => { + const source = + "entity user {}\nentity document {\n relation owner @user\n permission edit = owner or viewer\n}"; + await fs.create("source.perm", source); + await fs.create( + "permify.config.ts", + `export default { + tenant: "t1", + client: { endpoint: "localhost:3478", insecure: true }, + schema: ${localSchema} + };` + ); + + const output = captureOutput(); + try { + await runCommand( + SchemaDiff as any, + ["--source", `${fs.basePath}/source.perm`], + { cwd: fs.basePath } + ); + } finally { + output.restore(); + } + + const text = stripAnsi(output.logs.join("")); + assert.include(text, "~ document"); + assert.include(text, "~ edit"); + }); +}); From c3d27e737eacf5d2734eacaa0e8dece0008e0f2f Mon Sep 17 00:00:00 2001 From: thisisnkc Date: Tue, 7 Apr 2026 14:18:03 +0530 Subject: [PATCH 4/4] feat: add schema diff command docs --- .changeset/schema-diff-command.md | 13 ++ README.md | 2 +- docs-site/docs/packages/cli.md | 111 +++++++++++++- docs-site/docs/packages/core.md | 152 +++++++++++++++++--- docs-site/static/img/schema-diff-output.png | Bin 0 -> 34494 bytes 5 files changed, 254 insertions(+), 24 deletions(-) create mode 100644 .changeset/schema-diff-command.md create mode 100644 docs-site/static/img/schema-diff-output.png diff --git a/.changeset/schema-diff-command.md b/.changeset/schema-diff-command.md new file mode 100644 index 0000000..b781b15 --- /dev/null +++ b/.changeset/schema-diff-command.md @@ -0,0 +1,13 @@ +--- +"@permify-toolkit/core": minor +"@permify-toolkit/cli": minor +--- + +feat: add schema diff command to preview changes before pushing + +- New CLI command `schema diff` compares local schema against the deployed schema on the Permify server +- Supports local-vs-local comparison via `--source` flag (no server needed) +- Structural summary shows added, removed, and modified entities, relations, and permissions +- `--verbose` flag shows a unified text diff for full detail +- `--exit-code` flag exits with code 1 when changes are detected (for CI pipelines) +- New core exports: `readSchemaFromPermify`, `diffSchema`, `textDiff` diff --git a/README.md b/README.md index 2a07e19..a313002 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ We're actively working on expanding the toolkit. Here's what's coming: - [x] Relationship query CLI commands — list, inspect, and export existing relationships from a tenant - [ ] Permission result caching — in-memory (and optionally Redis-backed) cache to reduce gRPC round-trips - [ ] OpenTelemetry tracing — structured spans and metrics for permission checks and schema operations -- [ ] Schema diff CLI command — preview what will change before pushing a schema update +- [x] Schema diff CLI command — preview what will change before pushing a schema update - [ ] Multi-tenant CLI management — create, list, and delete tenants directly from the CLI Have ideas? [Open an issue](https://github.com/thisisnkc/permify-toolkit/issues) or start a [discussion](https://github.com/thisisnkc/permify-toolkit/discussions)! diff --git a/docs-site/docs/packages/cli.md b/docs-site/docs/packages/cli.md index f3e2bd6..3eb3a06 100644 --- a/docs-site/docs/packages/cli.md +++ b/docs-site/docs/packages/cli.md @@ -1,5 +1,6 @@ --- sidebar_position: 3 +toc_max_heading_level: 4 --- # @permify-toolkit/cli @@ -88,7 +89,9 @@ The `--tenant` flag is **optional** if `tenant` is defined in `permify.config.ts ## Commands -### `schema push` +### Schema + +#### `schema push` Pushes the schema defined in your config to the Permify server. @@ -124,7 +127,7 @@ The Permify server validates your schema on push. If there are errors, you'll se Error: Entity "usr" referenced in relation "document.owner" does not exist ``` -### `schema validate` +#### `schema validate` Validates your schema locally without connecting to a Permify server. Catches structural errors, broken references, permission cycles, and suspicious patterns before you push. @@ -184,7 +187,105 @@ permify-toolkit schema validate && permify-toolkit schema push Run `schema validate` before `schema push` for instant local feedback, no server connection needed. ::: -### `relationships seed` +#### `schema diff` + +Previews what will change before pushing a schema update. Compares your local schema (from `permify.config.ts`) against the schema currently deployed on the Permify server — or against another local `.perm` file. + +```bash +permify-toolkit schema diff [--tenant ] [flags] +``` + +**Flags:** + +| Flag | Alias | Description | Required | Default | +| ----------------- | ----- | ------------------------------------------------------------------ | -------- | ----------- | +| `--tenant` | | Tenant ID to diff against | No | From config | +| `--create-tenant` | `-c` | Create tenant if it doesn't exist | No | `false` | +| `--source` | `-s` | Path to a `.perm` file to compare against (local-vs-local mode) | No | | +| `--verbose` | `-v` | Show raw unified text diff after the structural summary | No | `false` | +| `--exit-code` | `-e` | Exit with code 1 if changes are detected (useful for CI pipelines) | No | `false` | + +**Examples:** + +```bash +# Compare local schema against what's deployed on the server +permify-toolkit schema diff + +# Compare against a specific tenant +permify-toolkit schema diff --tenant staging + +# Compare two local schemas (no server connection needed) +permify-toolkit schema diff --source ./old-schema.perm + +# Include a unified text diff for full detail +permify-toolkit schema diff --verbose + +# CI mode — fail the pipeline if schema has drifted +permify-toolkit schema diff --exit-code +``` + +**Output:** + +The command shows a structural summary of changes at the entity, relation, and permission level: + +![Schema Diff Output](/img/schema-diff-output.png) + +- **Green (`+`)** — added entities, relations, or permissions +- **Red (`-`)** — removed entities, relations, or permissions +- **Yellow (`~`)** — modified entities (contents changed), or individual relations/permissions whose definition changed + +When no changes are detected: + +``` +✔ Schema is up to date — no changes detected (tenant: t1) +``` + +When no schema exists on the remote server yet (first-time push), everything is shown as additions: + +``` +Schema Diff — tenant: t1 +ℹ No schema found on remote — showing full schema as additions +``` + +With `--verbose`, a unified text diff is appended below the structural summary: + +```diff +--- remote (tenant: t1) ++++ local (permify.config.ts) +@@ -1,5 +1,8 @@ + entity user {} + entity document { + relation owner @user ++ relation editor @user +- permission view = owner ++ permission view = owner or editor ++ permission edit = editor + } +``` + +**Exit codes:** + +| Code | Meaning | +| ---- | ------------------------------------------------------- | +| `0` | No changes detected, or changes detected (default mode) | +| `1` | Changes detected and `--exit-code` flag is set | + +:::tip Use in CI pipelines +Combine `--exit-code` with your CI to detect schema drift: + +```bash +permify-toolkit schema diff --exit-code || echo "Schema has changed — review before pushing" +``` + +::: + +:::tip Preview before push +Run `schema diff` before `schema push` to review exactly what will change on the server. Pair with `--verbose` for a complete picture. +::: + +### Relationships + +#### `relationships seed` Seeds relationship data from a JSON file. @@ -234,7 +335,7 @@ permify-toolkit relationships seed --tenant my-tenant-id --file-path ./data/rela permify-toolkit relationships seed --tenant new-tenant-id --file-path ./relationships.json --create-tenant ``` -### `relationships list` +#### `relationships list` Queries and displays relationship tuples from a Permify tenant. Useful for debugging authorization — quickly see what relationships exist for a given entity type. @@ -320,7 +421,7 @@ When no relationships match: Use `relationships list` to verify that the tuples you expect actually exist in Permify. If a permission check fails unexpectedly, list the relationships for that entity to see what's stored. ::: -### `relationships export` +#### `relationships export` Exports relationship tuples from a Permify tenant to a JSON file. The output format is identical to the `relationships seed` input format — so you can export from one tenant and seed into another. diff --git a/docs-site/docs/packages/core.md b/docs-site/docs/packages/core.md index d7e5948..e273223 100644 --- a/docs-site/docs/packages/core.md +++ b/docs-site/docs/packages/core.md @@ -190,6 +190,119 @@ await deleteRelationships(client, { }); ``` +## Reading Schemas + +Read the currently deployed schema from a Permify server. This is useful for inspecting what's live, building migration scripts, or comparing against a local definition. + +```typescript +import { + createPermifyClient, + readSchemaFromPermify +} from "@permify-toolkit/core"; + +const client = createPermifyClient({ + endpoint: "localhost:3478", + insecure: true +}); + +const result = await readSchemaFromPermify({ + client, + tenantId: "my-tenant" +}); + +if (result.schema) { + console.log("Deployed schema DSL:\n", result.schema); + console.log("Entities:", Object.keys(result.entities)); + // e.g. { user: { relations: {}, permissions: {} }, document: { relations: { owner: "@user" }, permissions: { edit: "owner" } } } +} else { + console.log("No schema deployed yet for this tenant."); +} +``` + +The returned `entities` map contains each entity's relations and permissions with their definitions — relation targets (e.g., `@user`) and permission expressions (e.g., `owner or editor`). + +## Schema Diffing + +Compare two schemas structurally to see what entities, relations, and permissions were added, removed, or changed. This powers the CLI's `schema diff` command, but you can use it directly for custom CI checks, migration previews, or programmatic workflows. + +### Structural Diff + +`diffSchema` compares two entity maps and returns a structured result: + +```typescript +import { + readSchemaFromPermify, + diffSchema, + type SchemaEntityMap +} from "@permify-toolkit/core"; + +// Define your local schema as a flat entity map +const local: SchemaEntityMap = { + user: { relations: {}, permissions: {} }, + document: { + relations: { owner: "@user", editor: "@user" }, + permissions: { view: "owner or editor", edit: "owner" } + } +}; + +// Read the deployed schema from the server +const remote = await readSchemaFromPermify({ client, tenantId: "my-tenant" }); + +const result = diffSchema(local, remote.entities); + +if (!result.hasChanges) { + console.log("Schemas are identical."); +} else { + for (const entity of result.added) { + console.log(`+ New entity: ${entity.name}`); + } + for (const entity of result.removed) { + console.log(`- Removed entity: ${entity.name}`); + } + for (const entity of result.modified) { + console.log(`~ Modified: ${entity.name}`); + console.log(" Relations added:", entity.relations.added); + console.log(" Relations removed:", entity.relations.removed); + console.log(" Relations changed:", entity.relations.changed); + console.log(" Permissions added:", entity.permissions.added); + console.log(" Permissions removed:", entity.permissions.removed); + console.log(" Permissions changed:", entity.permissions.changed); + } +} +``` + +This is useful for building custom guardrails — for example, failing a deploy if permissions were removed without an explicit approval step. + +### Text Diff + +`textDiff` generates a unified diff (like `git diff`) between two schema DSL strings: + +```typescript +import { textDiff } from "@permify-toolkit/core"; + +const remoteDsl = + "entity user {}\nentity document {\n relation owner @user\n permission view = owner\n}"; +const localDsl = + "entity user {}\nentity document {\n relation owner @user\n relation editor @user\n permission view = owner or editor\n}"; + +const diff = textDiff(localDsl, remoteDsl, "local", "remote"); + +if (diff) { + console.log(diff); + // --- remote + // +++ local + // @@ -2,4 +2,5 @@ + // entity document { + // relation owner @user + // + relation editor @user + // - permission view = owner + // + permission view = owner or editor + // } +} +``` + +This pairs well with `diffSchema` — use the structural diff for programmatic decisions, and the text diff for human-readable output in logs or PR comments. + ## Centralized Configuration Create a `permify.config.ts` in your root. The `defineConfig` helper makes your config compatible with CLI and NestJS: @@ -210,21 +323,24 @@ export default defineConfig({ ### Exports -| Export | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------------- | -| `schema()` | Create a schema definition | -| `entity()` | Define an entity type | -| `relation()` | Define a relation on an entity | -| `attribute()` | Define an attribute on an entity | -| `permission()` | Define a permission rule | -| `defineConfig()` | Create a typed config object | -| `validateConfig()` | Validate a config object | -| `schemaFile()` | Reference a `.perm` schema file | -| `createPermifyClient()` | Create a gRPC client | -| `clientOptionsFromEnv()` | Read client options from env vars | -| `checkPermission()` | Check a permission | -| `writeRelationships()` | Write relationship tuples | -| `readRelationships()` | Read relationship tuples with filtering and automatic pagination | -| `deleteRelationships()` | Delete relationship tuples | -| `relationsOf()` | Helper to extract relations from schema | -| `getSchemaWarnings()` | Collect non-blocking warnings from a schema AST (unused relations, empty entities, missing permissions) | +| Export | Description | +| ------------------------- | ------------------------------------------------------------------------------------------------------- | +| `schema()` | Create a schema definition | +| `entity()` | Define an entity type | +| `relation()` | Define a relation on an entity | +| `attribute()` | Define an attribute on an entity | +| `permission()` | Define a permission rule | +| `defineConfig()` | Create a typed config object | +| `validateConfig()` | Validate a config object | +| `schemaFile()` | Reference a `.perm` schema file | +| `createPermifyClient()` | Create a gRPC client | +| `clientOptionsFromEnv()` | Read client options from env vars | +| `checkPermission()` | Check a permission | +| `writeRelationships()` | Write relationship tuples | +| `readRelationships()` | Read relationship tuples with filtering and automatic pagination | +| `deleteRelationships()` | Delete relationship tuples | +| `relationsOf()` | Helper to extract relations from schema | +| `getSchemaWarnings()` | Collect non-blocking warnings from a schema AST (unused relations, empty entities, missing permissions) | +| `readSchemaFromPermify()` | Read the current schema from a Permify server for a given tenant | +| `diffSchema()` | Compute a structural diff between two schema entity maps | +| `textDiff()` | Generate a unified text diff between two DSL strings | diff --git a/docs-site/static/img/schema-diff-output.png b/docs-site/static/img/schema-diff-output.png new file mode 100644 index 0000000000000000000000000000000000000000..9cc3b603de633496bf391ef98a158f6252c010b2 GIT binary patch literal 34494 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 Y(LYi(UTTtq0Q^XcE4(ieHTe9$05to-X#fBK literal 0 HcmV?d00001