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 0000000..9cc3b60 Binary files /dev/null and b/docs-site/static/img/schema-diff-output.png differ diff --git a/packages/cli/src/commands/schema/diff.ts b/packages/cli/src/commands/schema/diff.ts new file mode 100644 index 0000000..f3328ed --- /dev/null +++ b/packages/cli/src/commands/schema/diff.ts @@ -0,0 +1,248 @@ +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({ + 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 + }) + }; + + 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/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"); + }); +}); 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"; +} 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"); + }); + } +);